diff --git a/.github/workflows/loristest.yml b/.github/workflows/loristest.yml index f22d777e214..8c9b3a38fe3 100644 --- a/.github/workflows/loristest.yml +++ b/.github/workflows/loristest.yml @@ -52,7 +52,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: ['8.1', '8.2'] + php: ['8.1', '8.2', '8.3'] steps: - uses: actions/checkout@v2 @@ -104,7 +104,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.1', '8.2'] + php: ['8.1', '8.2', '8.3'] apiversion: ['v0.0.3', 'v0.0.4-dev'] steps: - uses: actions/checkout@v2 @@ -167,9 +167,8 @@ jobs: mysql ${{ env.DB_DATABASE}} -uroot -proot < SQL/0000-00-03-ConfigTables.sql mysql ${{ env.DB_DATABASE}} -uroot -proot < SQL/0000-00-04-Help.sql mysql ${{ env.DB_DATABASE}} -uroot -proot < SQL/0000-00-05-ElectrophysiologyTables.sql - find raisinbread/instruments/instrument_sql -name *.sql -exec sh -c "echo Sourcing {}; mysql ${{ env.DB_DATABASE}} -uroot -proot < {}" \; + find raisinbread/instruments/instrument_sql -name *.sql -not -name 9999-99-99-drop_instrument_tables.sql -exec sh -c "echo Sourcing {}; mysql ${{ env.DB_DATABASE}} -uroot -proot < {}" \; find raisinbread/RB_files/ -name *.sql -exec sh -c "echo Sourcing {}; mysql ${{ env.DB_DATABASE}} -uroot -proot < {}" \; - - name: Source instrument schemas run: | find raisinbread/instruments/instrument_sql -name 0000-*.sql -exec sh -c "echo Sourcing {}; mysql ${{ env.DB_DATABASE}} -uroot -proot < {}" \; @@ -210,7 +209,7 @@ jobs: fail-fast: false matrix: testsuite: ['integration'] - php: ['8.1','8.2'] + php: ['8.1','8.2', '8.3'] ci_node_index: [0,1,2,3] include: @@ -221,10 +220,14 @@ jobs: php: '8.1' - testsuite: 'static' php: '8.2' + - testsuite: 'static' + php: '8.3' - testsuite: 'unit' php: '8.1' - testsuite: 'unit' php: '8.2' + - testsuite: 'unit' + php: '8.3' steps: - uses: actions/checkout@v2 diff --git a/.phan/config.php b/.phan/config.php index 5d863032e0e..3821e8f7c53 100644 --- a/.phan/config.php +++ b/.phan/config.php @@ -38,7 +38,7 @@ "php", "htdocs", "modules", - "src", + "src", "vendor", "test" ], diff --git a/CHANGELOG.md b/CHANGELOG.md index b9c75f69d4c..91346b860b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ changes in the following format: PR #1234*** #### Features - Add OpenID Connect authorization support to LORIS (PR #8255) +#### Updates and Improvements +- Create new `sex` table to hold candidate sex options, and change Sex and ProbandSex columns of `candidate` table to a varchar(255) datatype that is restricted by the `sex` table (PR #9025) + #### Bug Fixes - Fix examiner site display (PR #8967) - bvl_feedback updates in real-time (PR #8966) diff --git a/Dockerfile.test.db b/Dockerfile.test.db index 1066960948b..78e12dc17e8 100644 --- a/Dockerfile.test.db +++ b/Dockerfile.test.db @@ -1,4 +1,4 @@ -FROM mysql:5.7 +FROM mariadb:10.5 ARG BASE_DIR diff --git a/SQL/0000-00-00-schema.sql b/SQL/0000-00-00-schema.sql index 979a87e47c3..39b3528a133 100644 --- a/SQL/0000-00-00-schema.sql +++ b/SQL/0000-00-00-schema.sql @@ -72,6 +72,13 @@ CREATE TABLE `language` ( INSERT INTO language (language_code, language_label) VALUES ('en-CA', 'English'); +CREATE TABLE `sex` ( + `Name` varchar(255) NOT NULL, + PRIMARY KEY `Name` (`Name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='Stores sex options available for candidates in LORIS'; + +INSERT INTO sex (Name) VALUES ('Male'), ('Female'), ('Other'); + CREATE TABLE `users` ( `ID` int(10) unsigned NOT NULL auto_increment, `UserID` varchar(255) NOT NULL default '', @@ -151,7 +158,7 @@ CREATE TABLE `candidate` ( `DoB` date DEFAULT NULL, `DoD` date DEFAULT NULL, `EDC` date DEFAULT NULL, - `Sex` enum('Male','Female','Other') DEFAULT NULL, + `Sex` varchar(255) DEFAULT NULL, `RegistrationCenterID` integer unsigned NOT NULL DEFAULT '0', `RegistrationProjectID` int(10) unsigned NOT NULL, `Ethnicity` varchar(255) DEFAULT NULL, @@ -166,7 +173,7 @@ CREATE TABLE `candidate` ( `flagged_other_status` enum('not_answered') DEFAULT NULL, `Testdate` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `Entity_type` enum('Human','Scanner') NOT NULL DEFAULT 'Human', - `ProbandSex` enum('Male','Female','Other') DEFAULT NULL, + `ProbandSex` varchar(255) DEFAULT NULL, `ProbandDoB` date DEFAULT NULL, PRIMARY KEY (`CandID`), UNIQUE KEY `ID` (`ID`), @@ -175,9 +182,13 @@ CREATE TABLE `candidate` ( KEY `CandidateActive` (`Active`), KEY `FK_candidate_2_idx` (`flagged_reason`), KEY `PSCID` (`PSCID`), + KEY `FK_candidate_sex_1` (`Sex`), + KEY `FK_candidate_sex_2` (`ProbandSex`), CONSTRAINT `FK_candidate_1` FOREIGN KEY (`RegistrationCenterID`) REFERENCES `psc` (`CenterID`), CONSTRAINT `FK_candidate_2` FOREIGN KEY (`flagged_reason`) REFERENCES `caveat_options` (`ID`) ON DELETE RESTRICT ON UPDATE CASCADE, - CONSTRAINT `FK_candidate_RegistrationProjectID` FOREIGN KEY (`RegistrationProjectID`) REFERENCES `Project` (`ProjectID`) ON UPDATE CASCADE + CONSTRAINT `FK_candidate_RegistrationProjectID` FOREIGN KEY (`RegistrationProjectID`) REFERENCES `Project` (`ProjectID`) ON UPDATE CASCADE, + CONSTRAINT `FK_candidate_sex_1` FOREIGN KEY (`Sex`) REFERENCES `sex` (`Name`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `FK_candidate_sex_2` FOREIGN KEY (`ProbandSex`) REFERENCES `sex` (`Name`) ON DELETE RESTRICT ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `session` ( diff --git a/SQL/0000-00-05-ElectrophysiologyTables.sql b/SQL/0000-00-05-ElectrophysiologyTables.sql index 3860a11e1ad..3e08c05e2e9 100644 --- a/SQL/0000-00-05-ElectrophysiologyTables.sql +++ b/SQL/0000-00-05-ElectrophysiologyTables.sql @@ -382,105 +382,6 @@ CREATE TABLE `physiological_archive` ( ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8; --- SQL tables for BIDS derivative file structure --- Create physiological_annotation_file_type table -CREATE TABLE `physiological_annotation_file_type` ( - `FileType` VARCHAR(20) NOT NULL UNIQUE, - `Description` VARCHAR(255), - PRIMARY KEY (`FileType`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - --- Create physiological_annotation_file table -CREATE TABLE `physiological_annotation_file` ( - `AnnotationFileID` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, - `PhysiologicalFileID` INT(10) UNSIGNED NOT NULL, - `FileType` VARCHAR(20) NOT NULL, - `FilePath` VARCHAR(255), - `LastUpdate` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `LastWritten` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`AnnotationFileID`), - CONSTRAINT `FK_phys_file_ID` - FOREIGN KEY (`PhysiologicalFileID`) - REFERENCES `physiological_file` (`PhysiologicalFileID`), - CONSTRAINT `FK_annotation_file_type` - FOREIGN KEY (`FileType`) - REFERENCES `physiological_annotation_file_type` (`FileType`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - --- Create annotation_archive which will store archives of all the annotation files for --- Front-end download -CREATE TABLE `physiological_annotation_archive` ( - `AnnotationArchiveID` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, - `PhysiologicalFileID` INT(10) UNSIGNED NOT NULL, - `Blake2bHash` VARCHAR(128) NOT NULL, - `FilePath` VARCHAR(255) NOT NULL, - PRIMARY KEY (`AnnotationArchiveID`), - CONSTRAINT `FK_physiological_file_ID` - FOREIGN KEY (`PhysiologicalFileID`) - REFERENCES `physiological_file` (`PhysiologicalFileID`) - ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - --- Create annotation_parameter table --- Note: This corresponds with the JSON annotation files -CREATE TABLE `physiological_annotation_parameter` ( - `AnnotationParameterID` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, - `AnnotationFileID` INT(10) UNSIGNED NOT NULL, - `Description` TEXT DEFAULT NULL, - `Sources` VARCHAR(255), - `Author` VARCHAR(255), - PRIMARY KEY (`AnnotationParameterID`), - CONSTRAINT `FK_annotation_file_ID` - FOREIGN KEY (`AnnotationFileID`) - REFERENCES `physiological_annotation_file` (`AnnotationFileID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - --- Create an annotation_label_type table -CREATE TABLE `physiological_annotation_label` ( - `AnnotationLabelID` INT(5) UNSIGNED NOT NULL AUTO_INCREMENT, - `AnnotationFileID` INT(10) UNSIGNED DEFAULT NULL, - `LabelName` VARCHAR(255) NOT NULL, - `LabelDescription` TEXT DEFAULT NULL, - PRIMARY KEY (`AnnotationLabelID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - --- Create annotation_tsv table --- Note: This corresponds with the .tsv annotation files -CREATE TABLE `physiological_annotation_instance` ( - `AnnotationInstanceID` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, - `AnnotationFileID` INT(10) UNSIGNED NOT NULL, - `AnnotationParameterID` INT(10) UNSIGNED NOT NULL, - `Onset` DECIMAL(10, 4), - `Duration` DECIMAL(10, 4) DEFAULT 0, - `AnnotationLabelID` INT(5) UNSIGNED NOT NULL, - `Channels` TEXT, - `AbsoluteTime` TIMESTAMP, - `Description` VARCHAR(255), - PRIMARY KEY (`AnnotationInstanceID`), - CONSTRAINT `FK_annotation_parameter_ID` - FOREIGN KEY (`AnnotationParameterID`) - REFERENCES `physiological_annotation_parameter` (`AnnotationParameterID`), - CONSTRAINT `FK_annotation_file` - FOREIGN KEY (`AnnotationFileID`) - REFERENCES `physiological_annotation_file` (`AnnotationFileID`), - CONSTRAINT `FK_annotation_label_ID` - FOREIGN KEY (`AnnotationLabelID`) - REFERENCES `physiological_annotation_label` (`AnnotationLabelID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - --- Create physiological_annotation_rel table -CREATE TABLE `physiological_annotation_rel` ( - `AnnotationTSV` INT(10) UNSIGNED NOT NULL, - `AnnotationJSON` INT(10) UNSIGNED NOT NULL, - PRIMARY KEY (`AnnotationTSV`, `AnnotationJSON`), - CONSTRAINT `FK_AnnotationTSV` - FOREIGN KEY (`AnnotationTSV`) - REFERENCES `physiological_annotation_file` (`AnnotationFileID`), - CONSTRAINT `FK_AnnotationJSON` - FOREIGN KEY (`AnnotationJSON`) - REFERENCES `physiological_annotation_file` (`AnnotationFileID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - -- Create EEG upload table CREATE TABLE `electrophysiology_uploader` ( `UploadID` int(10) unsigned NOT NULL AUTO_INCREMENT, @@ -634,39 +535,3 @@ INSERT INTO ImagingFileTypes ('edf', 'European data format (EEG)'), ('cnt', 'Neuroscan CNT data format (EEG)'), ('archive', 'Archive file'); - --- Insert into annotation_file_type -INSERT INTO physiological_annotation_file_type - (FileType, Description) - VALUES - ('tsv', 'TSV File Type, contains information about each annotation'), - ('json', 'JSON File Type, metadata for annotations'); - --- Insert into annotation_label_type -INSERT INTO physiological_annotation_label - (AnnotationLabelID, LabelName, LabelDescription) - VALUES - (1, 'artifact', 'artifactual data'), - (2, 'motion', 'motion related artifact'), - (3, 'flux_jump', 'artifactual data due to flux jump'), - (4, 'line_noise', 'artifactual data due to line noise (e.g., 50Hz)'), - (5, 'muscle', 'artifactual data due to muscle activity'), - (6, 'epilepsy_interictal', 'period deemed interictal'), - (7, 'epilepsy_preictal', 'onset of preictal state prior to onset of epilepsy'), - (8, 'epilepsy_seizure', 'onset of epilepsy'), - (9, 'epilepsy_postictal', 'postictal seizure period'), - (10, 'epileptiform', 'unspecified epileptiform activity'), - (11, 'epileptiform_single', 'a single epileptiform graphoelement (including possible slow wave)'), - (12, 'epileptiform_run', 'a run of one or more epileptiform graphoelements'), - (13, 'eye_blink', 'Eye blink'), - (14, 'eye_movement', 'Smooth Pursuit / Saccadic eye movement'), - (15, 'eye_fixation', 'Fixation onset'), - (16, 'sleep_N1', 'sleep stage N1'), - (17, 'sleep_N2', 'sleep stage N2'), - (18, 'sleep_N3', 'sleep stage N3'), - (19, 'sleep_REM', 'REM sleep'), - (20, 'sleep_wake', 'sleep stage awake'), - (21, 'sleep_spindle', 'sleep spindle'), - (22, 'sleep_k-complex', 'sleep K-complex'), - (23, 'scorelabeled', 'a global label indicating that the EEG has been annotated with SCORE.'); - diff --git a/SQL/New_patches/2024-01-29-Physiological-Events-Replace-Annotations.sql b/SQL/New_patches/2024-01-29-Physiological-Events-Replace-Annotations.sql new file mode 100644 index 00000000000..3ca77e98491 --- /dev/null +++ b/SQL/New_patches/2024-01-29-Physiological-Events-Replace-Annotations.sql @@ -0,0 +1,17 @@ +-- Dropping all tables regarding annotations +DROP TABLE physiological_annotation_archive; +DROP TABLE physiological_annotation_rel; +DROP TABLE physiological_annotation_instance; +DROP TABLE physiological_annotation_parameter; +DROP TABLE physiological_annotation_label; +DROP TABLE physiological_annotation_file; +DROP TABLE physiological_annotation_file_type; + +-- Event files are always associated to Projects, sometimes exclusively (dataset-scope events.json files) +-- Add ProjectID and make PhysiologicalFileID DEFAULT NULL (ProjectID should ideally not be NULLable) +ALTER TABLE `physiological_event_file` + CHANGE `PhysiologicalFileID` `PhysiologicalFileID` int(10) unsigned DEFAULT NULL, + ADD COLUMN `ProjectID` int(10) unsigned DEFAULT NULL AFTER `PhysiologicalFileID`, + ADD KEY `FK_physiological_event_file_project_id` (`ProjectID`), + ADD CONSTRAINT `FK_physiological_event_file_project_id` + FOREIGN KEY (`ProjectID`) REFERENCES `Project` (`ProjectID`); diff --git a/SQL/New_patches/2024-01-29-create-sex-table.sql b/SQL/New_patches/2024-01-29-create-sex-table.sql new file mode 100644 index 00000000000..d19be5cfb30 --- /dev/null +++ b/SQL/New_patches/2024-01-29-create-sex-table.sql @@ -0,0 +1,14 @@ +CREATE TABLE `sex` ( + `Name` varchar(255) NOT NULL, + PRIMARY KEY `Name` (`Name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='Stores sex options available for candidates in LORIS'; + +INSERT INTO sex (Name) VALUES ('Male'), ('Female'), ('Other'); + +ALTER TABLE candidate + MODIFY COLUMN sex varchar(255) DEFAULT NULL, + MODIFY COLUMN ProbandSex varchar(255) DEFAULT NULL, + ADD KEY `FK_candidate_sex_1` (`Sex`), + ADD KEY `FK_candidate_sex_2` (`ProbandSex`), + ADD CONSTRAINT `FK_candidate_sex_1` FOREIGN KEY (`Sex`) REFERENCES `sex` (`Name`) ON DELETE RESTRICT ON UPDATE CASCADE, + ADD CONSTRAINT `FK_candidate_sex_2` FOREIGN KEY (`ProbandSex`) REFERENCES `sex` (`Name`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/composer.lock b/composer.lock index b571fea7248..fe6c544184a 100644 --- a/composer.lock +++ b/composer.lock @@ -644,22 +644,22 @@ }, { "name": "laminas/laminas-diactoros", - "version": "2.22.0", + "version": "2.26.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-diactoros.git", - "reference": "df8c7f9e11d854269f4aa7c06ffa38caa42e4405" + "reference": "6584d44eb8e477e89d453313b858daac6183cddc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/df8c7f9e11d854269f4aa7c06ffa38caa42e4405", - "reference": "df8c7f9e11d854269f4aa7c06ffa38caa42e4405", + "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/6584d44eb8e477e89d453313b858daac6183cddc", + "reference": "6584d44eb8e477e89d453313b858daac6183cddc", "shasum": "" }, "require": { - "php": "~8.0.0 || ~8.1.0 || ~8.2.0", + "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0", "psr/http-factory": "^1.0", - "psr/http-message": "^1.0" + "psr/http-message": "^1.1" }, "conflict": { "zendframework/zend-diactoros": "*" @@ -674,11 +674,11 @@ "ext-gd": "*", "ext-libxml": "*", "http-interop/http-factory-tests": "^0.9.0", - "laminas/laminas-coding-standard": "^2.4.0", - "php-http/psr7-integration-tests": "^1.1.1", - "phpunit/phpunit": "^9.5.26", - "psalm/plugin-phpunit": "^0.18.0", - "vimeo/psalm": "^4.29.0" + "laminas/laminas-coding-standard": "^2.5", + "php-http/psr7-integration-tests": "^1.2", + "phpunit/phpunit": "^9.5.28", + "psalm/plugin-phpunit": "^0.18.4", + "vimeo/psalm": "^5.6" }, "type": "library", "extra": { @@ -737,7 +737,7 @@ "type": "community_bridge" } ], - "time": "2022-11-22T05:54:54+00:00" + "time": "2023-10-29T16:17:44+00:00" }, { "name": "mtdowling/jmespath.php", @@ -1090,25 +1090,25 @@ }, { "name": "psr/http-message", - "version": "1.0.1", + "version": "1.1", "source": { "type": "git", "url": "https://github.com/php-fig/http-message.git", - "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", - "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.1.x-dev" } }, "autoload": { @@ -1137,9 +1137,9 @@ "response" ], "support": { - "source": "https://github.com/php-fig/http-message/tree/master" + "source": "https://github.com/php-fig/http-message/tree/1.1" }, - "time": "2016-08-06T14:39:51+00:00" + "time": "2023-04-04T09:50:52+00:00" }, { "name": "psr/http-server-handler", @@ -2607,28 +2607,29 @@ }, { "name": "phpspec/prophecy", - "version": "v1.16.0", + "version": "v1.18.0", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "be8cac52a0827776ff9ccda8c381ac5b71aeb359" + "reference": "d4f454f7e1193933f04e6500de3e79191648ed0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/be8cac52a0827776ff9ccda8c381ac5b71aeb359", - "reference": "be8cac52a0827776ff9ccda8c381ac5b71aeb359", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/d4f454f7e1193933f04e6500de3e79191648ed0c", + "reference": "d4f454f7e1193933f04e6500de3e79191648ed0c", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.2", - "php": "^7.2 || 8.0.* || 8.1.* || 8.2.*", + "doctrine/instantiator": "^1.2 || ^2.0", + "php": "^7.2 || 8.0.* || 8.1.* || 8.2.* || 8.3.*", "phpdocumentor/reflection-docblock": "^5.2", - "sebastian/comparator": "^3.0 || ^4.0", - "sebastian/recursion-context": "^3.0 || ^4.0" + "sebastian/comparator": "^3.0 || ^4.0 || ^5.0", + "sebastian/recursion-context": "^3.0 || ^4.0 || ^5.0" }, "require-dev": { "phpspec/phpspec": "^6.0 || ^7.0", - "phpunit/phpunit": "^8.0 || ^9.0" + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^8.0 || ^9.0 || ^10.0" }, "type": "library", "extra": { @@ -2661,6 +2662,7 @@ "keywords": [ "Double", "Dummy", + "dev", "fake", "mock", "spy", @@ -2668,9 +2670,9 @@ ], "support": { "issues": "https://github.com/phpspec/prophecy/issues", - "source": "https://github.com/phpspec/prophecy/tree/v1.16.0" + "source": "https://github.com/phpspec/prophecy/tree/v1.18.0" }, - "time": "2022-11-29T15:06:56+00:00" + "time": "2023-12-07T16:22:33+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -5245,5 +5247,5 @@ "ext-json": "*" }, "platform-dev": [], - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } diff --git a/jsx/Filter.js b/jsx/Filter.js index d6b8bd18a57..cf0561cc75f 100644 --- a/jsx/Filter.js +++ b/jsx/Filter.js @@ -1,12 +1,13 @@ import React, {useEffect} from 'react'; import PropTypes from 'prop-types'; import { - SelectElement, + CheckboxElement, DateElement, - TextboxElement, - FormElement, FieldsetElement, - CheckboxElement, + FormElement, + NumericElement, + SelectElement, + TextboxElement, } from 'jsx/Form'; /** diff --git a/modules/battery_manager/jsx/batteryManagerForm.js b/modules/battery_manager/jsx/batteryManagerForm.js index 133a9c4308b..c41342c18dd 100644 --- a/modules/battery_manager/jsx/batteryManagerForm.js +++ b/modules/battery_manager/jsx/batteryManagerForm.js @@ -1,5 +1,12 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; +import { + ButtonElement, + FormElement, + StaticElement, + SelectElement, + NumericElement, +} from 'jsx/Form'; /** * Battery Manager Form diff --git a/modules/battery_manager/jsx/batteryManagerIndex.js b/modules/battery_manager/jsx/batteryManagerIndex.js index 27daacd40ee..794ba742f61 100644 --- a/modules/battery_manager/jsx/batteryManagerIndex.js +++ b/modules/battery_manager/jsx/batteryManagerIndex.js @@ -6,6 +6,7 @@ import Loader from 'Loader'; import FilterableDataTable from 'FilterableDataTable'; import Modal from 'Modal'; import swal from 'sweetalert2'; +import {CTA} from 'jsx/Form'; import BatteryManagerForm from './batteryManagerForm'; diff --git a/modules/candidate_list/jsx/candidateListIndex.js b/modules/candidate_list/jsx/candidateListIndex.js index f671603dea4..c11de28810e 100644 --- a/modules/candidate_list/jsx/candidateListIndex.js +++ b/modules/candidate_list/jsx/candidateListIndex.js @@ -282,11 +282,7 @@ class CandidateListIndex extends Component { name: 'sex', type: 'select', hide: this.state.hideFilter, - options: { - 'Male': 'Male', - 'Female': 'Female', - 'Other': 'Other', - }, + options: options.Sex, }, }, { diff --git a/modules/candidate_list/php/candidate_list.class.inc b/modules/candidate_list/php/candidate_list.class.inc index 6f7d59bf27a..af9bc43c5be 100644 --- a/modules/candidate_list/php/candidate_list.class.inc +++ b/modules/candidate_list/php/candidate_list.class.inc @@ -119,6 +119,7 @@ class Candidate_List extends \DataFrameworkMenu 'cohort' => $cohort_options, 'participantstatus' => $participant_status_options, 'useedc' => $config->getSetting("useEDC"), + 'Sex' => \Utility::getSexList(), ]; } diff --git a/modules/candidate_parameters/ajax/getData.php b/modules/candidate_parameters/ajax/getData.php index b3fadc87803..b8612657698 100644 --- a/modules/candidate_parameters/ajax/getData.php +++ b/modules/candidate_parameters/ajax/getData.php @@ -220,6 +220,7 @@ function getProbandInfoFields() 'ageDifference' => $ageDifference, 'extra_parameters' => $extra_parameters, 'parameter_values' => $parameter_values, + 'sexOptions' => \Utility::getSexList(), ]; return $result; diff --git a/modules/candidate_parameters/jsx/ProbandInfo.js b/modules/candidate_parameters/jsx/ProbandInfo.js index 0f57214a6a3..460838b144a 100644 --- a/modules/candidate_parameters/jsx/ProbandInfo.js +++ b/modules/candidate_parameters/jsx/ProbandInfo.js @@ -1,6 +1,14 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import Loader from 'Loader'; +import { + FormElement, + StaticElement, + SelectElement, + ButtonElement, + DateElement, + TextareaElement, +} from 'jsx/Form'; /** * Proband Info Component. @@ -16,11 +24,7 @@ class ProbandInfo extends Component { super(props); this.state = { - sexOptions: { - Male: 'Male', - Female: 'Female', - Other: 'Other', - }, + sexOptions: {}, Data: [], formData: {}, updateResult: null, @@ -66,6 +70,7 @@ class ProbandInfo extends Component { formData: formData, Data: data, isLoaded: true, + sexOptions: data.sexOptions, }); }, error: (error) => { diff --git a/modules/candidate_parameters/php/candidatequeryengine.class.inc b/modules/candidate_parameters/php/candidatequeryengine.class.inc index 9f2326f4d13..31a7e4c594e 100644 --- a/modules/candidate_parameters/php/candidatequeryengine.class.inc +++ b/modules/candidate_parameters/php/candidatequeryengine.class.inc @@ -4,6 +4,7 @@ namespace LORIS\candidate_parameters; use LORIS\Data\Scope; use LORIS\Data\Cardinality; use LORIS\Data\Dictionary\DictionaryItem; +use LORIS\Data\Types\Enumeration; /** * A CandidateQueryEngine providers a QueryEngine interface to query @@ -54,6 +55,8 @@ class CandidateQueryEngine extends \LORIS\Data\Query\SQLQueryEngine "Demographics", "Candidate Demographics", ); + $sexList = \Utility::getSexList(); + $t = new Enumeration(...$sexList); $demographics = $demographics->withItems( [ new DictionaryItem( @@ -74,7 +77,7 @@ class CandidateQueryEngine extends \LORIS\Data\Query\SQLQueryEngine "Sex", "Candidate's biological sex", $candscope, - new \LORIS\Data\Types\Enumeration('Male', 'Female', 'Other'), + $t, new Cardinality(Cardinality::SINGLE), ), new DictionaryItem( @@ -193,9 +196,10 @@ class CandidateQueryEngine extends \LORIS\Data\Query\SQLQueryEngine case 'PSCID': return 'c.PSCID'; case 'Site': - $this->addTable('LEFT JOIN session s ON (s.CandID=c.CandID)'); + $this->addTable( + "LEFT JOIN session s ON (s.CandID=c.CandID AND s.Active='Y')" + ); $this->addTable('LEFT JOIN psc site ON (s.CenterID=site.CenterID)'); - $this->addWhereClause("s.Active='Y'"); return 'site.Name'; case 'RegistrationSite': $this->addTable( @@ -212,11 +216,12 @@ class CandidateQueryEngine extends \LORIS\Data\Query\SQLQueryEngine case 'EDC': return 'c.EDC'; case 'Project': - $this->addTable('LEFT JOIN session s ON (s.CandID=c.CandID)'); + $this->addTable( + "LEFT JOIN session s ON (s.CandID=c.CandID AND s.Active='Y')" + ); $this->addTable( 'LEFT JOIN Project proj ON (s.ProjectID=proj.ProjectID)' ); - $this->addWhereClause("s.Active='Y'"); return 'proj.Name'; case 'RegistrationProject': @@ -226,17 +231,18 @@ class CandidateQueryEngine extends \LORIS\Data\Query\SQLQueryEngine ); return 'rproj.Name'; case 'Cohort': - $this->addTable('LEFT JOIN session s ON (s.CandID=c.CandID)'); + $this->addTable( + "LEFT JOIN session s ON (s.CandID=c.CandID AND s.Active='Y')" + ); $this->addTable( 'LEFT JOIN cohort cohort' .' ON (s.CohortID=cohort.CohortID)' ); - $this->addWhereClause("s.Active='Y'"); - return 'cohort.title'; case 'VisitLabel': - $this->addTable('LEFT JOIN session s ON (s.CandID=c.CandID)'); - $this->addWhereClause("s.Active='Y'"); + $this->addTable( + "LEFT JOIN session s ON (s.CandID=c.CandID AND s.Active='Y')" + ); return 's.Visit_label'; case 'EntityType': return 'c.Entity_type'; @@ -254,18 +260,35 @@ class CandidateQueryEngine extends \LORIS\Data\Query\SQLQueryEngine } } - /** * {@inheritDoc} * - * @param string $fieldname A field name + * @param \LORIS\Data\Dictionary\DictionaryItem $item - The LORIS dictionary item * * @return string */ - protected function getCorrespondingKeyField($fieldname) - { + protected function getCorrespondingKeyField( + \LORIS\Data\Dictionary\DictionaryItem $item + ) { // There are no cardinality::many fields in this query engine, so this // should never get called - throw new \Exception("Unhandled Cardinality::MANY field $fieldname"); + throw new \Exception( + "Unhandled Cardinality::MANY field " . $item->getName() + ); + } + + /** + * {@inheritDoc} + * + * @param \LORIS\Data\Dictionary\DictionaryItem $item - The LORIS dictionary item + * + * @return string + */ + public function getCorrespondingKeyFieldType( + \LORIS\Data\Dictionary\DictionaryItem $item + ) : string { + throw new \Exception( + "Unhandled Cardinality::MANY field " . $item->getName() + ); } } diff --git a/modules/candidate_parameters/test/candidateQueryEngineTest.php b/modules/candidate_parameters/test/candidateQueryEngineTest.php index 9a8094a2753..152de8e47e3 100644 --- a/modules/candidate_parameters/test/candidateQueryEngineTest.php +++ b/modules/candidate_parameters/test/candidateQueryEngineTest.php @@ -940,16 +940,6 @@ function testVisitLabelMatches() ); $this->assertMatchNone($result); - $result = $this->engine->getCandidateMatches( - new QueryTerm($candiddict, new IsNull()) - ); - $this->assertMatchNone($result); - - $result = $this->engine->getCandidateMatches( - new QueryTerm($candiddict, new NotNull()) - ); - $this->assertMatchOne($result, "123456"); - $result = $this->engine->getCandidateMatches( new QueryTerm($candiddict, new StartsWith("V")) ); @@ -1038,16 +1028,6 @@ function testProjectMatches() ); $this->assertMatchOne($result, "123456"); - $result = $this->engine->getCandidateMatches( - new QueryTerm($candiddict, new IsNull()) - ); - $this->assertMatchNone($result); - - $result = $this->engine->getCandidateMatches( - new QueryTerm($candiddict, new NotNull()) - ); - $this->assertMatchOne($result, "123456"); - $result = $this->engine->getCandidateMatches( new QueryTerm($candiddict, new StartsWith("Test")) ); @@ -1137,16 +1117,6 @@ function testSiteMatches() ); $this->assertMatchOne($result, "123456"); - $result = $this->engine->getCandidateMatches( - new QueryTerm($candiddict, new IsNull()) - ); - $this->assertMatchNone($result); - - $result = $this->engine->getCandidateMatches( - new QueryTerm($candiddict, new NotNull()) - ); - $this->assertMatchOne($result, "123456"); - $result = $this->engine->getCandidateMatches( new QueryTerm($candiddict, new StartsWith("Test")) ); @@ -1357,14 +1327,6 @@ function testParticipantStatusMatches() */ function testGetCandidateData() { - // By default the SQLQueryEngine uses an unbuffered query. However, - // this creates a new database connection which doesn't have access - // to our temporary tables. Since for this test we're only dealing - // with 1 module and don't need to run multiple queries in parallel, - // we can turn on buffered query access to re-use the same DB - // connection and maintain our temporary tables. - $this->engine->useQueryBuffering(true); - // Test getting some candidate scoped data $results = iterator_to_array( $this->engine->getCandidateData( @@ -1760,41 +1722,45 @@ function testGetCandidateDataMemory() ); $this->DB->run("DROP TEMPORARY TABLE IF EXISTS candidate"); + $this->DB->run("DROP TEMPORARY TABLE IF EXISTS session"); $this->DB->setFakeTableData("candidate", []); + $this->DB->setFakeTableData("session", []); for ($i = 100000; $i < 100010; $i++) { $insert->execute([$i, $i, "Test$i"]); } $memory10 = memory_get_peak_usage(); - for ($i = 100010; $i < 100200; $i++) { + $bigSize = 20000; + + for ($i = 100010; $i < (100000 + $bigSize); $i++) { $insert->execute([$i, $i, "Test$i"]); } - $memory200 = memory_get_peak_usage(); + $memoryBig = memory_get_peak_usage(); // Ensure that the memory used by php didn't change whether // a prepared statement was executed 10 or 200 times. Any // additional memory should have been used by the SQL server, // not by PHP. - $this->assertTrue($memory10 == $memory200); + $this->assertTrue($memory10 == $memoryBig); $cand10 = []; - $cand200 = []; + $candBig = []; // Allocate the CandID array for both tests upfront to // ensure we're measuring memory used by getCandidateData // and not the size of the arrays passed as arguments. for ($i = 100000; $i < 100010; $i++) { $cand10[] = new CandID("$i"); - $cand200[] = new CandID("$i"); + $candBig[] = new CandID("$i"); } - for ($i = 100010; $i < 102000; $i++) { - $cand200[] = new CandID("$i"); + for ($i = 100010; $i < (100000 + $bigSize); $i++) { + $candBig[] = new CandID("$i"); } $this->assertEquals(count($cand10), 10); - $this->assertEquals(count($cand200), 2000); + $this->assertEquals(count($candBig), $bigSize); $results10 = $this->engine->getCandidateData( [$this->_getDictItem("PSCID")], @@ -1803,9 +1769,6 @@ function testGetCandidateDataMemory() ); $memory10data = memory_get_usage(); - // There should have been some overhead for the - // generator - //$this->assertTrue($memory10data > $memory200); // Go through all the data returned and measure // memory usage after. @@ -1823,27 +1786,28 @@ function testGetCandidateDataMemory() // Now see how much memory is used by iterating over // 200 candidates - $results200 = $this->engine->getCandidateData( + $resultsBig = $this->engine->getCandidateData( [$this->_getDictItem("PSCID")], - $cand200, + $candBig, null, ); - $memory200data = memory_get_usage(); - + $memoryBigDataBefore = memory_get_usage(); $i = 100000; - foreach ($results200 as $candid => $data) { + foreach ($resultsBig as $candid => $data) { $this->assertEquals($candid, $i); // $this->assertEquals($data['PSCID'], "Test$i"); $i++; } - $memory200dataAfter = memory_get_usage(); - $iterator200usage = $memory200dataAfter - $memory200data; + $memoryBigDataAfter = memory_get_usage(); + $iteratorBigUsage = $memoryBigDataAfter - $memoryBigDataBefore; - $memory200peak = memory_get_peak_usage(); - $this->assertTrue($iterator200usage == $iterator10usage); - $this->assertEquals($memory10peak, $memory200peak); + $memoryBigPeak = memory_get_peak_usage(); + // We tested 20,000 candidates. Give 2k buffer room for variation in + // memory usage. + $this->assertTrue($iteratorBigUsage <= ($iterator10usage + (1024*2))); + $this->assertTrue($memoryBigPeak <= ($memory10peak + (1024*2))); $this->DB->run("DROP TEMPORARY TABLE IF EXISTS candidate"); } diff --git a/modules/configuration/jsx/DiagnosisEvolution.js b/modules/configuration/jsx/DiagnosisEvolution.js index 9a31cde9125..76b0a7ccc57 100644 --- a/modules/configuration/jsx/DiagnosisEvolution.js +++ b/modules/configuration/jsx/DiagnosisEvolution.js @@ -406,7 +406,7 @@ class DiagnosisEvolution extends Component { }); } }).catch((error) => { - console.log(error); + console.warn(error); }); } @@ -501,7 +501,7 @@ class DiagnosisEvolution extends Component { }); } }).catch((error) => { - console.log(error); + console.warn(error); }); } diff --git a/modules/data_release/jsx/addPermissionForm.js b/modules/data_release/jsx/addPermissionForm.js index 191465be5b0..d4a5aa370de 100644 --- a/modules/data_release/jsx/addPermissionForm.js +++ b/modules/data_release/jsx/addPermissionForm.js @@ -2,6 +2,11 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import Loader from 'jsx/Loader'; import swal from 'sweetalert2'; +import { + FormElement, + SelectElement, + ButtonElement, +} from 'jsx/Form'; /** * Add Permission Form diff --git a/modules/data_release/jsx/managePermissionsForm.js b/modules/data_release/jsx/managePermissionsForm.js index 13703e74d73..958a81095ae 100644 --- a/modules/data_release/jsx/managePermissionsForm.js +++ b/modules/data_release/jsx/managePermissionsForm.js @@ -3,6 +3,11 @@ import PropTypes from 'prop-types'; import Loader from 'jsx/Loader'; import swal from 'sweetalert2'; import Modal from 'Modal'; +import { + FormElement, + CheckboxElement, + StaticElement, +} from 'jsx/Form'; /** * Manage Permissions Form diff --git a/modules/data_release/jsx/uploadFileForm.js b/modules/data_release/jsx/uploadFileForm.js index e938ec27686..68043b02a02 100644 --- a/modules/data_release/jsx/uploadFileForm.js +++ b/modules/data_release/jsx/uploadFileForm.js @@ -2,7 +2,13 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import ProgressBar from 'ProgressBar'; import swal from 'sweetalert2'; - +import { + FormElement, + StaticElement, + FileElement, + TextboxElement, + ButtonElement, +} from 'jsx/Form'; /** * Upload File Form diff --git a/modules/dataquery/jsx/viewdata.tsx b/modules/dataquery/jsx/viewdata.tsx index 709ab67812c..e402212f199 100644 --- a/modules/dataquery/jsx/viewdata.tsx +++ b/modules/dataquery/jsx/viewdata.tsx @@ -4,7 +4,7 @@ import {useState, useEffect, ReactNode} from 'react'; import fetchDataStream from 'jslib/fetchDataStream'; import DataTable from 'jsx/DataTable'; -import {SelectElement} from 'jsx/Form'; +import {SelectElement, CheckboxElement} from 'jsx/Form'; import {APIQueryField, APIQueryObject} from './types'; import {QueryGroup} from './querydef'; import {FullDictionary, FieldDictionary} from './types'; @@ -18,13 +18,9 @@ type JSONString = string; type SessionRowCell = { VisitLabel: string; value?: string - values?: string[] + values?: {[keyid: string]: string} }; -type KeyedValue = { - value: string; - key: string; -} /** * Convert a piece of data from JSON to the format to be displayed @@ -278,6 +274,7 @@ function ViewData(props: { props.fields, props.fulldictionary ); + const [emptyVisits, setEmptyVisits] = useState(true); let queryTable; if (queryData.loading) { @@ -324,6 +321,7 @@ function ViewData(props: { visitOrganization, props.fields, props.fulldictionary, + emptyVisits, ) } hide={ @@ -345,6 +343,17 @@ function ViewData(props: { } } + const emptyCheckbox = (visitOrganization === 'inline' ? + + setEmptyVisits(value) + } + /> + :
); return
+ {emptyCheckbox} {queryTable}
; } @@ -438,7 +448,8 @@ function organizeData( } const cellobj: any = JSON.parse(candidaterow[i]); for (const session in cellobj) { - if (!cellobj.hasOwnProperty(session)) { + if (!cellobj.hasOwnProperty(session) + || session === 'keytype') { continue; } const vl: string = cellobj[session].VisitLabel; @@ -474,10 +485,34 @@ function organizeData( dataRow.push(null); break; case 1: - if (typeof values[0].value === 'undefined') { + switch (dictionary.cardinality) { + case 'many': + if (typeof values[0].values === 'undefined') { + dataRow.push(null); + } else { + const thevalues = values[0].values; + // I don't think this if statement should be required because of the + // above if statement, but without it typescript gives an error + // about Object.keys on possible type undefined. + if (!thevalues) { + dataRow.push(null); + } else { + const mappedVals = Object.keys(thevalues) + .map( + (key) => key + '=' + thevalues[key] + ) + .join(';'); + dataRow.push(mappedVals); + } + } + break; + default: + if (typeof values[0].value === 'undefined') { dataRow.push(null); - } else { + } else { dataRow.push(values[0].value); + } + break; } break; default: @@ -617,25 +652,47 @@ function expandLongitudinalCells( if (!displayedVisits) { displayedVisits = []; } - const values = displayedVisits.map((visit) => { + let celldata: {[sessionid: string]: SessionRowCell}; + try { + celldata = JSON.parse(value || '{}'); + } catch (e) { + // This can sometimes happen when we go between Cross-Sectional + // and Longitudinal and the data is in an inconsistent state + // between renders, so instead of throwing an error (which crashes + // the whole app), we just log to the console and return null. + console.error('Internal error parsing: "' + value + '"'); + return null; + } + const values = displayedVisits.map((visit): string|null => { if (!value) { return null; } - try { - const data = JSON.parse(value); - for (const session in data) { - if (data[session].VisitLabel == visit) { - return data[session].value; + for (const session in celldata) { + if (celldata[session].VisitLabel == visit) { + const thissession: SessionRowCell = celldata[session]; + switch (fielddict.cardinality) { + case 'many': + if (thissession.values === undefined) { + return null; + } + const thevalues = thissession.values; + return Object.keys(thevalues) + .map( (key) => key + '=' + thevalues[key]) + .join(';'); + default: + if (thissession.value !== undefined) { + return thissession.value; + } + throw new Error('Value was undefined'); } } - return null; - } catch (e) { - throw new Error('Internal error'); } + return null; }); return values; } } + /** * Return a cell formatter specific to the options chosen * @@ -644,13 +701,17 @@ function expandLongitudinalCells( * option selected * @param {array} fields - The fields selected * @param {array} dict - The full dictionary - * @returns {function} - the appropriate column formatter for this data organization + * @param {boolean} displayEmptyVisits - Whether visits with + no data should be displayed + * @returns {function} - the appropriate column formatter for + this data organization */ function organizedFormatter( resultData: string[][], visitOrganization: VisitOrgType, fields: APIQueryField[], - dict: FullDictionary + dict: FullDictionary, + displayEmptyVisits: boolean ) { let callback; switch (visitOrganization) { @@ -693,15 +754,26 @@ function organizedFormatter( if (fielddict === null) { return null; } - if (fielddict.scope == 'candidate' - && fielddict.cardinality != 'many') { + if (fielddict.scope == 'candidate') { if (cell === '') { return (No data); } - - return ; + switch (fielddict.cardinality) { + case 'many': + return (Not implemented); + case 'single': + case 'unique': + case 'optional': + return ; + default: + return ( + (Internal Error. Unhandled cardinality: + {fielddict.cardinality}) + + ); + } } - let val; + let val: React.ReactNode; if (fielddict.scope == 'session') { let displayedVisits: string[]; if (fields[fieldNo] && fields[fieldNo].visits) { @@ -711,13 +783,107 @@ function organizedFormatter( } else { // All visits if (fielddict.visits) { - displayedVisits = fielddict.visits; + displayedVisits = fielddict.visits; } else { - displayedVisits = []; + displayedVisits = []; } } + switch (fielddict.cardinality) { + case 'many': + val = displayedVisits.map((visit): React.ReactNode => { + let hasdata = false; + /** + * Map the JSON string from the cell returned by the + * API to a string to display to the user in the + * frontend for this visit. + * + * @param {string} visit - The visit being displayed + * @param {string} cell - The raw cell value + * @returns {string|null} - the display string + */ + const visitval = (visit: string, cell: string) => { + if (cell === '') { + return null; + } - val = displayedVisits.map((visit) => { + try { + const json = JSON.parse(cell); + for (const sessionid in json) { + if (json[sessionid].VisitLabel == visit) { + const values = json[sessionid].values; + return (
{ + Object.keys(values).map( + (keyid: string): + React.ReactNode => { + let val = values[keyid]; + if (val === null) { + return; + } + const ftyp = fielddict.type; + if (ftyp == 'URI') { + val = ( + + {val} + + ); + } + hasdata = true; + return ( +
+
{keyid}
+
{val}
+
+ ); + }) + } +
); + } + } + return null; + } catch (e) { + console.error(e); + return (Internal error); + } + }; + let theval = visitval(visit, cell); + if (!displayEmptyVisits && !hasdata) { + return
; + } + if (theval === null) { + theval = (No data); + } + return (
+
{visit} +
+
+ {theval} +
+
); + }); + break; + default: + val = displayedVisits.map((visit) => { + let hasdata = false; /** * Maps the JSON value from the session to a list of * values to display to the user @@ -729,26 +895,35 @@ function organizedFormatter( */ const visitval = (visit: string, cell: string) => { if (cell === '') { - return (No data); + return null; } try { const json = JSON.parse(cell); for (const sessionid in json) { if (json[sessionid].VisitLabel == visit) { - if (fielddict.cardinality === 'many') { - return valuesList( - json[sessionid].values - ); - } else { - return json[sessionid].value; + hasdata = true; + if (json[sessionid].value === true) { + return 'True'; + } else if ( + json[sessionid].value === false + ) { + return 'False'; } + return json[sessionid].value; } } } catch (e) { return (Internal error); } - return (No data); + return null; }; + let theval = visitval(visit, cell); + if (!displayEmptyVisits && !hasdata) { + return
; + } + if (theval === null) { + theval = (No data); + } return (
- {visitval(visit, cell)} + {theval}
); }); - } else { - return FIXME: {cell}; + } } const value = (
{ - return
  • {val.value}
  • ; - }); - return (
      - {items} -
    ); -} - type VisitOrgType = 'raw' | 'inline' | 'longitudinal' | 'crosssection'; type HeaderDisplayType = 'fieldname' | 'fielddesc' | 'fieldnamedesc'; /** diff --git a/modules/dataquery/jsx/welcome.tsx b/modules/dataquery/jsx/welcome.tsx index 0e05b9c34de..c5b0a0bea36 100644 --- a/modules/dataquery/jsx/welcome.tsx +++ b/modules/dataquery/jsx/welcome.tsx @@ -20,7 +20,6 @@ import {FlattenedField, FlattenedQuery, VisitOption} from './types'; * @param {FlattenedQuery[]} props.topQueries - List of top queries to display pinned to the top of the tab * @param {FlattenedQuery[]} props.sharedQueries - List of queries shared with the current user * @param {function} props.onContinue - Callback when the "Continue" button is called in the welcome message - * @param {boolean} props.useAdminName - True if the display should display the admin name of the query * @param {boolean} props.queryAdmin - True if the current user can pin study queries * @param {function} props.reloadQueries - Reload the list of queries from the server * @param {function} props.loadQuery - Load a query to replace the active query @@ -169,6 +168,7 @@ function Welcome(props: { * @param {object} props - React props * @param {FlattenedQuery[]} props.queries - The list of queries to show in the list * @param {boolean} props.queryAdmin - True if the current user can pin study queries + * @param {boolean} props.useAdminName - True if the display should display the admin name of the query * @param {boolean} props.defaultCollapsed - True if the queries should default to be collapsed * @param {function} props.starQuery - Function that will star a query * @param {function} props.unstarQuery - Function that will unstar a query @@ -679,6 +679,7 @@ function Pager(props: { * @param {function} props.setNameModalID - Function that will set the queryID to show a name modal for * @param {boolean} props.showFullQueryDefault - True if the query should be expanded by default * @param {boolean} props.queryAdmin - True if the admin query options (ie. pin query) should be shown + * @param {boolean} props.useAdminName - True if the display should display the admin name of the query * @param {function} props.setAdminModalID - Function that will set the queryID to show an admin modal for * @param {object} props.mapModuleName - Function to map the backend module name to a user friendly name * @param {object} props.mapCategoryName - Function to map the backend category name to a user friendly name diff --git a/modules/document_repository/jsx/editForm.js b/modules/document_repository/jsx/editForm.js index e6caa3184ec..1cf138aeac3 100644 --- a/modules/document_repository/jsx/editForm.js +++ b/modules/document_repository/jsx/editForm.js @@ -2,6 +2,14 @@ import Loader from 'Loader'; import PropTypes from 'prop-types'; import swal from 'sweetalert2'; +import { + FormElement, + TextboxElement, + TextareaElement, + SelectElement, + ButtonElement, + FileElement, +} from 'jsx/Form'; /** * Document Edit Form * diff --git a/modules/dqt/jsx/react.importCSV.js b/modules/dqt/jsx/react.importCSV.js index fa1e4edfa64..5e12469c5a3 100644 --- a/modules/dqt/jsx/react.importCSV.js +++ b/modules/dqt/jsx/react.importCSV.js @@ -7,7 +7,11 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import Modal from 'jsx/Modal'; import Papa from 'papaparse'; - +import { + FileElement, + RadioElement, + ButtonElement, +} from 'jsx/Form'; /** * Import CSV Modal Component * diff --git a/modules/dqt/jsx/react.tabs.js b/modules/dqt/jsx/react.tabs.js index 2a14c3004de..481e9685862 100644 --- a/modules/dqt/jsx/react.tabs.js +++ b/modules/dqt/jsx/react.tabs.js @@ -12,7 +12,9 @@ import React, {Component, useState} from 'react'; import PropTypes from 'prop-types'; import StaticDataTable from '../../../jsx/StaticDataTable'; import swal from 'sweetalert2'; - +import { + RadioElement, +} from 'jsx/Form'; const {jStat} = require('jstat'); import JSZip from 'jszip'; diff --git a/modules/electrophysiology_browser/README.md b/modules/electrophysiology_browser/README.md index 25bb1398b44..c1c7d89a618 100644 --- a/modules/electrophysiology_browser/README.md +++ b/modules/electrophysiology_browser/README.md @@ -4,14 +4,14 @@ The Electrophysiology Browser is intended to allow users to view candidate electrophysiology (EEG, MEG...) sessions collected for a study and any associated -annotations for each recording. +events for each recording. ## Intended Users The primary types of users are: 1. Electrophysiology researchers who want to know details about the inserted datasets. 2. Site coordinators or researchers ensuring the uploaded electrophysiology data have -been correctly inserted into LORIS. + been correctly inserted into LORIS. ## Scope @@ -26,22 +26,22 @@ sufficient to provide access to view data in the module. The third permission pr permissions to add or modify annotations for data from the sites the user has access to in this module. electrophysiology_browser_view_allsites - - This permission gives the user access to all electrophysiology datasets present in the database. + - This permission gives the user access to all electrophysiology datasets present in the database. electrophysiology_browser_view_site - - This permission gives the user access to electrophysiology datasets from their own site(s) only. + - This permission gives the user access to electrophysiology datasets from their own site(s) only. electrophysiology_browser_edit_annotations - - This permission allows the user to add, edit, and delete annotations for raw or derived datasets + - This permission allows the user to add, edit, and delete annotations for raw or derived datasets ## Download You can download all the files related to a recording (channel information, -electrode information, task event information, the actual recording) -- as well as its annotations and their related metadata. +electrode information, task event information, the actual recording) -- as well as its events and their related metadata. ## Updating Derivative Files -New annotations or edits to existing annotations made through the browser must also be updated in the derivative files stored in the filesystem, before a user tries to download a derivative file package. To do this automatically, a script is provided under `tools/update_annotation_files.php`, and a cron job should be set up to execute it regularly, e.g. every evening. +New events or edits to existing events made through the browser must also be updated in the derivative files stored in the filesystem, before a user tries to download a derivative file package. To do this automatically, a script is provided under `tools/update_event_files.php`, and a cron job should be set up to execute it regularly, e.g. every evening. ## Installation requirements to use the visualization features diff --git a/modules/electrophysiology_browser/css/electrophysiology_browser.css b/modules/electrophysiology_browser/css/electrophysiology_browser.css index b5516024df9..8a4300a663e 100644 --- a/modules/electrophysiology_browser/css/electrophysiology_browser.css +++ b/modules/electrophysiology_browser/css/electrophysiology_browser.css @@ -3,7 +3,7 @@ } .react-series-data-viewer-scoped .dropdown-menu li { - margin-top: 0; + margin: 0; padding: 0 10px; } @@ -14,6 +14,38 @@ width: 100%; } +.checkbox-flex-label > div > input[type="checkbox"] { + vertical-align: top; +} + +.checkbox-flex-label { + display: flex; + align-items: center; + margin-bottom: 0; + justify-content: flex-end; +} + +.btn-dropdown-toggle { + padding: 5px 10%; +} + +.col-xs-12 > .btn-dropdown-toggle { + padding: 5px; + max-width: fit-content; +} + +.col-xs-12 > .dropdown-menu { + width: max-content; + line-height: 14px; + padding: 0 +} + +.col-xs-12 > .dropdown-menu li { + margin: 0; + padding: 0; +} + + .btn.btn-xs { font-size: 12px; } @@ -46,42 +78,51 @@ svg:not(:root) { overflow: clip; } -.list-group-item { +.annotation.list-group-item { position: relative; display: flex; flex-direction: column; justify-content: space-between; align-items: center; + padding: 0; + width: 100%; } .annotation { background: #fffae6; - border-left: 5px solid #ff6600; + border-left: 5px solid #8eecfa; } .epoch-details { - padding-right: 100px; + display: flex; + width: 100%; + padding: 10px 0; } .epoch-action { display: flex; flex-direction: row; - justify-content: center; + justify-content: end; align-items: center; - position: absolute; - right: 10px; } .epoch-tag { - padding: 5px; + padding: 10px; background: #e7e4e4; - border-left: 5px solid #797878; + word-wrap: break-word; width: 100%; } -.epoch-tag p { - word-wrap: break-word; - width: 95%; +.line-height-14 { + line-height: 14px; +} + +.margin-top-10 { + margin-top: 10px; +} + +.flex-basis-45 { + flex-basis: 45% } .event-list .btn.btn-primary { @@ -111,8 +152,9 @@ svg:not(:root) { .btn-zoom { margin: 0 auto 3px auto; - width: 50px; + width: 55px; text-align: center; + text-wrap: unset; } .col-xs-title { @@ -139,7 +181,7 @@ svg:not(:root) { .electrode:hover circle { stroke: #064785; cursor: pointer; - fill: #E4EBF2 + fill: #E4EBF2; } .electrode:hover text { @@ -181,6 +223,10 @@ svg:not(:root) { width: auto; } +.cursor-default { + cursor: default; +} + /* Custom, iPhone Retina */ @media only screen and (min-width : 320px) { .pagination-nav { diff --git a/modules/electrophysiology_browser/help/sessions.md b/modules/electrophysiology_browser/help/sessions.md index 9a02d6dca8d..7aa591e5f2d 100644 --- a/modules/electrophysiology_browser/help/sessions.md +++ b/modules/electrophysiology_browser/help/sessions.md @@ -10,5 +10,5 @@ Files can be downloaded containing only the recording signal, the events, or oth - EEG: the file containing the session recording data. - Electrode info (tsv): contains electrode locations. - Channels info (tsv): channel status and filter settings. -- Events (tsv): events (both stimuli and responses) recorded during the session. -- Annotations (tsv): annotations (both stimuli and responses) recorded during the session. +- Events (tsv): events (both stimuli and responses) recorded during the session. + diff --git a/modules/electrophysiology_browser/jsx/components/DownloadPanel.js b/modules/electrophysiology_browser/jsx/components/DownloadPanel.js index 027395c1c0b..a254e1c8cbf 100644 --- a/modules/electrophysiology_browser/jsx/components/DownloadPanel.js +++ b/modules/electrophysiology_browser/jsx/components/DownloadPanel.js @@ -21,7 +21,7 @@ class DownloadPanel extends Component { downloads: this.props.downloads, physioFileID: this.props.physioFileID, annotationsAction: loris.BaseURL - + '/electrophysiology_browser/annotations', + + '/electrophysiology_browser/events', outputType: this.props.outputType, }; } @@ -54,27 +54,29 @@ class DownloadPanel extends Component { maxWidth: '250px', margin: '0 auto', } - }> + }> {Object.entries(panel.links).map(([type, download], j) => { const disabled = (download.file === ''); - return ( -
    + // Ignore physiological_coord_system_file + return type !== 'physiological_coord_system_file' + ? (
    {download.label}
    - {disabled - ? +
    {download.label}
    + {disabled + ?
    Not Available - : diff --git a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js index 53b8c9b2c74..134487f0498 100644 --- a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js +++ b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js @@ -139,10 +139,6 @@ class ElectrophysiologySessionView extends Component { type: 'physiological_task_event_file', file: '', }, - { - type: 'physiological_annotation_files', - file: '', - }, { type: 'all_files', file: '', @@ -152,8 +148,8 @@ class ElectrophysiologySessionView extends Component { chunksURL: null, epochsURL: null, electrodesURL: null, + coordSystemURL: null, events: null, - annotations: null, splitData: null, }, ], @@ -200,68 +196,73 @@ class ElectrophysiologySessionView extends Component { throw Error(resp.statusText); } return resp.json(); - }) - .then((data) => { - const database = data.database.map((dbEntry) => ({ - ...dbEntry, - // EEG Visualization urls - chunksURLs: - dbEntry - && dbEntry.file.chunks_urls.map( - (url) => - loris.BaseURL - + '/electrophysiology_browser/file_reader/?file=' - + url - ), - epochsURL: - dbEntry - && dbEntry.file?.epochsURL - && [loris.BaseURL + }).then((data) => { + const database = data.database.map((dbEntry) => ({ + ...dbEntry, + // EEG Visualization urls + chunksURLs: + dbEntry + && dbEntry.file.chunks_urls.map( + (url) => + loris.BaseURL + + '/electrophysiology_browser/file_reader/?file=' + + url + ), + epochsURL: + dbEntry + && dbEntry.file?.epochsURL + && [loris.BaseURL + + '/electrophysiology_browser/file_reader/?file=' + + dbEntry.file.epochsURL], + electrodesURL: + dbEntry + && dbEntry.file.downloads.map( + (group) => + group.links['physiological_electrode_file']?.file + && loris.BaseURL + '/electrophysiology_browser/file_reader/?file=' - + dbEntry.file.epochsURL], - electrodesURL: - dbEntry - && dbEntry.file.downloads.map( - (group) => - group.links['physiological_electrode_file']?.file - && loris.BaseURL - + '/electrophysiology_browser/file_reader/?file=' - + group.links['physiological_electrode_file'].file - ), - events: - dbEntry - && dbEntry.file.events, - annotations: - dbEntry - && dbEntry.file.annotations, - })); + + group.links['physiological_electrode_file'].file + ), + coordSystemURL: + dbEntry + && dbEntry.file.downloads.map( + (group) => + group.links['physiological_coord_system_file']?.file + && loris.BaseURL + + '/electrophysiology_browser/file_reader/?file=' + + group.links['physiological_coord_system_file'].file + ), + events: + dbEntry + && dbEntry.file.events, + })); - this.setState({ - setup: {data}, - isLoaded: true, - database: database, - patient: { - info: data.patient, - }, - }); + this.setState({ + setup: {data}, + isLoaded: true, + database: database, + patient: { + info: data.patient, + }, + }); - document.getElementById( - 'nav_next' - ).href = dataURL + data.nextSession + outputTypeArg; - document.getElementById( - 'nav_previous' - ).href = dataURL + data.prevSession + outputTypeArg; - if (data.prevSession !== '') { - document.getElementById('nav_previous').style.display = 'block'; - } - if (data.nextSession !== '') { - document.getElementById('nav_next').style.display = 'block'; - } - }) - .catch((error) => { - this.setState({error: true}); - console.error(error); - }); + document.getElementById( + 'nav_next' + ).href = dataURL + data.nextSession + outputTypeArg; + document.getElementById( + 'nav_previous' + ).href = dataURL + data.prevSession + outputTypeArg; + if (data.prevSession !== '') { + document.getElementById('nav_previous').style.display = 'block'; + } + if (data.nextSession !== '') { + document.getElementById('nav_next').style.display = 'block'; + } + }) + .catch((error) => { + this.setState({error: true}); + console.error(error); + }); } /** @@ -333,8 +334,8 @@ class ElectrophysiologySessionView extends Component { chunksURLs, epochsURL, events, - annotations, electrodesURL, + coordSystemURL, } = this.state.database[i]; const file = this.state.database[i].file; const splitPagination = []; @@ -365,8 +366,8 @@ class ElectrophysiologySessionView extends Component { } epochsURL={epochsURL} events={events} - annotations={annotations} electrodesURL={electrodesURL} + coordSystemURL={coordSystemURL} physioFileID={this.state.database[i].file.id} samplingFrequency={ this.state.database[i].file.summary[0].value @@ -431,7 +432,6 @@ class ElectrophysiologySessionView extends Component { }
    diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.tsx index 6eb6e76f3e4..a83ef9bc97e 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.tsx @@ -18,8 +18,11 @@ import { setFilteredEpochs, } from '../series/store/state/dataset'; import {setDomain, setInterval} from '../series/store/state/bounds'; -import {setElectrodes} from '../series/store/state/montage'; -import {AnnotationMetadata, EventMetadata} from '../series/store/types'; +import { + setCoordinateSystem, + setElectrodes, +} from '../series/store/state/montage'; +import {EventMetadata} from '../series/store/types'; declare global { interface Window { @@ -30,8 +33,8 @@ declare global { type CProps = { chunksURL: string, electrodesURL: string, + coordSystemURL: string, events: EventMetadata, - annotations: AnnotationMetadata, physioFileID: number, limit: number, samplingFrequency: number, @@ -62,8 +65,8 @@ class EEGLabSeriesProvider extends Component { const { chunksURL, electrodesURL, + coordSystemURL, events, - annotations, physioFileID, limit, samplingFrequency, @@ -121,62 +124,68 @@ class EEGLabSeriesProvider extends Component { }) ); this.store.dispatch(setChannels(emptyChannels( - Math.min(this.props.limit, channelMetadata.length), - 1 + Math.min(this.props.limit, channelMetadata.length), + 1 ))); this.store.dispatch(setDomain(timeInterval)); this.store.dispatch(setInterval(DEFAULT_TIME_INTERVAL)); } }).then(() => { - return events.instances.map((instance) => { - const onset = parseFloat(instance.Onset); - const duration = parseFloat(instance.Duration); - const label = instance.TrialType && instance.TrialType !== 'n/a' ? - instance.TrialType : instance.EventValue; - const hed = instance.AssembledHED; - return { - onset: onset, - duration: duration, - type: 'Event', - label: label, - comment: null, - hed: hed, - channels: 'all', - annotationInstanceID: null, - }; - }); - }).then((events) => { - const epochs = events; - annotations.instances.map((instance) => { - const label = annotations.labels - .find((label) => - label.AnnotationLabelID == instance.AnnotationLabelID - ).LabelName; - epochs.push({ - onset: parseFloat(instance.Onset), - duration: parseFloat(instance.Duration), - type: 'Annotation', - label: label, - comment: instance.Description, - hed: null, - channels: 'all', - annotationInstanceID: instance.AnnotationInstanceID, + const epochs = []; + events.instances.map((instance) => { + const epochIndex = + epochs.findIndex((e) => + e.physiologicalTaskEventID === + instance.PhysiologicalTaskEventID + ); + + const extraColumns = Array.from( + events.extraColumns + ).filter((column) => { + return column.PhysiologicalTaskEventID === + instance.PhysiologicalTaskEventID; }); - }); + if (epochIndex === -1) { + const epochLabel = [null, 'n/a'].includes(instance.TrialType) + ? null + : instance.TrialType; + epochs.push({ + onset: parseFloat(instance.Onset), + duration: parseFloat(instance.Duration), + type: 'Event', + label: epochLabel ?? instance.EventValue, + value: instance.EventValue, + trialType: instance.TrialType, + properties: extraColumns, + hed: null, + channels: 'all', + physiologicalTaskEventID: instance.PhysiologicalTaskEventID, + }); + } else { + console.error('ERROR: EPOCH EXISTS'); + } + }); return epochs; - }).then((epochs) => { - this.store.dispatch( - setEpochs( - epochs - .flat() - .sort(function(a, b) { - return a.onset - b.onset; - }) - ) - ); - this.store.dispatch(setFilteredEpochs(epochs.map((_, index) => index))); - }) - ; + }).then((epochs) => { + const sortedEpochs = epochs + .flat() + .sort(function(a, b) { + return a.onset - b.onset; + }); + + const timeInterval = this.store.getState().dataset.timeInterval; + this.store.dispatch(setEpochs(sortedEpochs)); + this.store.dispatch(setFilteredEpochs({ + plotVisibility: sortedEpochs.reduce((indices, epoch, index) => { + if (!(epoch.onset < 1 && epoch.duration >= timeInterval[1])) { + // Full-recording events not visible by default + indices.push(index); + } + return indices; + }, []), + columnVisibility: [], + })); + }); Promise.race(racers(fetchText, electrodesURL)) .then((text) => { @@ -195,6 +204,27 @@ class EEGLabSeriesProvider extends Component { .catch((error) => { console.error(error); }); + + Promise.race(racers(fetchJSON, coordSystemURL)) + .then( ({json, _}) => { + if (json) { + const { + EEGCoordinateSystem, + EEGCoordinateUnits, + EEGCoordinateSystemDescription, + } = json; + this.store.dispatch( + setCoordinateSystem({ + name: EEGCoordinateSystem ?? 'Other', + units: EEGCoordinateUnits ?? 'm', + description: EEGCoordinateSystemDescription ?? 'n/a', + }) + ); + } + }) + .catch((error) => { + console.error(error); + }); } /** diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.tsx index c07c0b12b25..377f06f5160 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.tsx @@ -1,6 +1,5 @@ import React, {useEffect, useState} from 'react'; import { - AnnotationMetadata, Epoch as EpochType, RightPanel, } from '../store/types'; @@ -12,7 +11,7 @@ import {toggleEpoch, updateActiveEpoch} from '../store/logic/filterEpochs'; import {RootState} from '../store'; import {setEpochs} from '../store/state/dataset'; import {setCurrentAnnotation} from '../store/state/currentAnnotation'; -import {NumericElement, SelectElement, TextareaElement} from './Form'; +import {NumericElement, SelectElement, TextboxElement} from './Form'; import swal from 'sweetalert2'; type CProps = { @@ -25,11 +24,9 @@ type CProps = { currentAnnotation: EpochType, setCurrentAnnotation: (_: EpochType) => void, physioFileID: number, - annotationMetadata: AnnotationMetadata, toggleEpoch: (_: number) => void, updateActiveEpoch: (_: number) => void, interval: [number, number], - domain: [number, number], }; /** @@ -47,7 +44,6 @@ type CProps = { * @param root0.toggleEpoch, * @param root0.updateActiveEpoch, * @param root0.interval - * @param root0.domain * @param root0.toggleEpoch * @param root0.updateActiveEpoch */ @@ -60,11 +56,9 @@ const AnnotationForm = ({ currentAnnotation, setCurrentAnnotation, physioFileID, - annotationMetadata, toggleEpoch, updateActiveEpoch, interval, - domain, }: CProps) => { const [startEvent = '', endEvent = ''] = timeSelection || []; const [event, setEvent] = useState<(number | string)[]>( @@ -78,11 +72,7 @@ const AnnotationForm = ({ currentAnnotation.label : null ); - const [comment, setComment] = useState( - currentAnnotation ? - currentAnnotation.comment : - '' - ); + const [isSubmitted, setIsSubmitted] = useState(false); const [isDeleted, setIsDeleted] = useState(false); const [annoMessage, setAnnoMessage] = useState(''); @@ -101,8 +91,6 @@ const AnnotationForm = ({ (event[0] || event[0] === 0) && (event[1] || event[1] === 0) && event[0] <= event[1] - && event[0] >= interval[0] && event[0] <= interval[1] - && event[1] >= interval[0] && event[1] <= interval[1] ); /** @@ -161,14 +149,7 @@ const AnnotationForm = ({ const handleLabelChange = (name, value) => { setLabel(value); }; - /** - * - * @param name - * @param value - */ - const handleCommentChange = (name, value) => { - setComment(value); - }; + /** * */ @@ -180,18 +161,14 @@ const AnnotationForm = ({ * */ const handleReset = () => { - // Clear all fields - setEvent(['', '']); - setTimeSelection([null, null]); - setLabel(''); - setComment(''); + // TODO: Clear all fields }; /** * */ const handleDelete = () => { - setIsDeleted(true); + // Not supported }; // Submit @@ -213,7 +190,7 @@ const AnnotationForm = ({ } const url = window.location.origin + - '/electrophysiology_browser/annotations/'; + '/electrophysiology_browser/events/'; // get duration of annotation let startTime = event[0]; @@ -229,9 +206,10 @@ const AnnotationForm = ({ // set body // instance_id = null for new annotations const body = { + request_type: 'event_update', physioFileID: physioFileID, instance_id: currentAnnotation ? - currentAnnotation.annotationInstanceID : + currentAnnotation.physiologicalTaskEventID : null, instance: { onset: startTime, @@ -239,22 +217,9 @@ const AnnotationForm = ({ label_name: label, label_description: label, channels: 'all', - description: comment, }, }; - const newAnnotation : EpochType = { - onset: startTime, - duration: duration, - type: 'Annotation', - label: label, - comment: comment, - channels: 'all', - annotationInstanceID: currentAnnotation ? - currentAnnotation.annotationInstanceID : - null, - }; - fetch(url, { method: 'POST', credentials: 'same-origin', @@ -263,15 +228,31 @@ const AnnotationForm = ({ if (response.ok) { return response.json(); } - }).then((data) => { + }).then((response) => { setIsSubmitted(false); // if in edit mode, remove old annotation instance if (currentAnnotation !== null) { epochs.splice(epochs.indexOf(currentAnnotation), 1); - } else { - newAnnotation.annotationInstanceID = parseInt(data.instance_id); } + + const data = response.instance; + + const epochLabel = [null, 'n/a'].includes(data.instance.TrialType) + ? null + : data.instance.TrialType; + const newAnnotation : EpochType = { + onset: parseFloat(data.instance.Onset), + duration: parseFloat(data.instance.Duration), + type: 'Event', + label: epochLabel ?? data.instance.EventValue, + value: data.instance.EventValue, + trialType: data.instance.TrialType, + properties: data.extraColumns, + channels: 'all', + physiologicalTaskEventID: data.instance.PhysiologicalTaskEventID, + }; + epochs.push(newAnnotation); setEpochs( epochs @@ -285,25 +266,27 @@ const AnnotationForm = ({ // Display success message setAnnoMessage(currentAnnotation ? - 'Annotation Updated!' : - 'Annotation Added!'); + 'Event Updated!' : + 'Event Added!'); setTimeout(() => { setAnnoMessage(''); // Empty string will cause success div to hide - - // If in edit mode, switch back to annotation panel - if (currentAnnotation !== null) { - setCurrentAnnotation(null); - setRightPanel('annotationList'); - } - }, 3000); + }, 2000); }).catch((error) => { console.error(error); // Display error message - swal.fire( - 'Error', - 'Something went wrong!', - 'error' - ); + if (error.status === 401) { + swal.fire( + 'Unauthorized', + 'This action is not permitted.', + 'error' + ); + } else { + swal.fire( + 'Error', + 'Something went wrong!', + 'error' + ); + } }); }, [isSubmitted]); @@ -311,11 +294,11 @@ const AnnotationForm = ({ useEffect(() => { if (isDeleted) { const url = window.location.origin - + '/electrophysiology_browser/annotations/'; + + '/electrophysiology_browser/events/'; const body = { physioFileID: physioFileID, instance_id: currentAnnotation ? - currentAnnotation.annotationInstanceID : + currentAnnotation.physiologicalTaskEventID : null, }; @@ -352,14 +335,14 @@ const AnnotationForm = ({ // Display success message swal.fire( 'Success', - 'Annotation Deleted!', + 'Event Deleted!', 'success' ); // If in edit mode, switch back to annotation panel if (currentAnnotation !== null) { setCurrentAnnotation(null); - setRightPanel('annotationList'); + setRightPanel('eventList'); } } }).catch((error) => { @@ -378,14 +361,6 @@ const AnnotationForm = ({ } }, [isDeleted]); - let labelOptions = {}; - annotationMetadata.labels.map((label) => { - labelOptions = { - ...labelOptions, - [label.LabelName]: label.LabelName, - }; - }); - return (
    - {currentAnnotation ? 'Edit' : 'Add'} Annotation + {currentAnnotation ? 'Edit' : 'Add'} Event { - setRightPanel('annotationList'); + setRightPanel('eventList'); setCurrentAnnotation(null); setTimeSelection(null); + updateActiveEpoch(null); }} >
    + Event Name + + { + currentAnnotation.label === currentAnnotation.trialType + ? 'trial_type' + : 'value' + } + + + } + value={currentAnnotation ? currentAnnotation.label : ""} + required={true} + readonly={true} + /> - - +
    + { + currentAnnotation && currentAnnotation.properties.length > 0 && ( + <> + +
    + { + currentAnnotation.properties.map((property) => { + return ( + + ); + }) + } +
    + + ) + } +
    + - {currentAnnotation && - - } {annoMessage && (
    ({ + physioFileID: state.dataset.physioFileID, timeSelection: state.timeSelection, epochs: state.dataset.epochs, - filteredEpochs: state.dataset.filteredEpochs, + filteredEpochs: state.dataset.filteredEpochs.plotVisibility, currentAnnotation: state.currentAnnotation, interval: state.bounds.interval, domain: state.bounds.domain, diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/EventManager.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/EventManager.tsx index 09f33ff29b8..de51f107835 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/EventManager.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/EventManager.tsx @@ -6,7 +6,7 @@ import { toggleEpoch, updateActiveEpoch, } from '../store/logic/filterEpochs'; -import {Epoch as EpochType, RightPanel} from '../store/types'; +import {Epoch as EpochType, EpochFilter, RightPanel} from '../store/types'; import {connect} from 'react-redux'; import {setTimeSelection} from '../store/state/timeSelection'; import {setRightPanel} from '../store/state/rightPanel'; @@ -17,14 +17,14 @@ import {setFilteredEpochs} from '../store/state/dataset'; type CProps = { timeSelection?: [number, number], epochs: EpochType[], - filteredEpochs: number[], + filteredEpochs: EpochFilter, rightPanel: RightPanel, setCurrentAnnotation: (_: EpochType) => void, setTimeSelection: (_: [number, number]) => void, setRightPanel: (_: RightPanel) => void, toggleEpoch: (_: number) => void, updateActiveEpoch: (_: number) => void, - setFilteredEpochs: (_: number[]) => void, + setFilteredEpochs: (_: EpochFilter) => void, interval: [number, number], viewerHeight: number, }; @@ -57,59 +57,58 @@ const EventManager = ({ interval, viewerHeight, }: CProps) => { - const [epochType, setEpochType] = useState((rightPanel - && rightPanel !== 'annotationForm' - && rightPanel === 'eventList') ? - 'Event' : 'Annotation'); - - const [activeEpochs, setActiveEpochs] = useState([]); - const [epochsInRange, setEpochsInRange] = useState( - getEpochsInRange(epochs, interval, epochType) - ); + const [epochsInRange, setEpochsInRange] = useState(getEpochsInRange(epochs, interval)); const [allEpochsVisible, setAllEpochsVisibility] = useState(() => { if (epochsInRange.length < MAX_RENDERED_EPOCHS) { return epochsInRange.some((index) => { - return !filteredEpochs.includes(index); - }); + return !filteredEpochs.plotVisibility.includes(index); + }) } return true; }); - const [visibleComments, setVisibleComments] = useState([]); const [allCommentsVisible, setAllCommentsVisible] = useState(false); - const totalEpochs = epochs.filter( - (epoch) => epoch.type === epochType - ).length; // Update window visibility state useEffect(() => { - setEpochsInRange(getEpochsInRange(epochs, interval, epochType)); - if (epochsInRange.length < MAX_RENDERED_EPOCHS) { - setAllEpochsVisibility(!epochsInRange.some((index) => { - return !filteredEpochs.includes(index); - })); // If one or more event isn't visible, set to be able to reveal all + const updatedEpochs = getEpochsInRange(epochs, interval); + + if (updatedEpochs.length > 0 && updatedEpochs.length < MAX_RENDERED_EPOCHS) { + setAllEpochsVisibility(!updatedEpochs.some((index) => { + return !filteredEpochs.plotVisibility.includes(index); + })); // If one or more event isn't visible, set to be able to reveal all } else { setAllEpochsVisibility(false); } - }, [filteredEpochs, interval]); - useEffect(() => { - // Toggle comment section if in range and has a comment / tag - if (!allCommentsVisible) { - setVisibleComments([]); + if (updatedEpochs.length > 0) { + setAllCommentsVisible(!updatedEpochs.some((epochIndex) => { + return epochs[epochIndex].properties.length > 0 + && !filteredEpochs.columnVisibility.includes(epochIndex); + })); } else { - - const commentIndexes = getEpochsInRange(epochs, interval, epochType, true) - .map((index) => index); - setVisibleComments([...commentIndexes]); + setAllCommentsVisible(false); } - }, [allCommentsVisible]); - useEffect(() => { - setEpochType((rightPanel - && rightPanel !== 'annotationForm' - && rightPanel === 'eventList') ? - 'Event' : 'Annotation'); - }, [rightPanel]); + setEpochsInRange(updatedEpochs); + }, [filteredEpochs, interval]); + + + const setCommentsInRangeVisibility = (visible) => { + let commentIndices = [...filteredEpochs.columnVisibility]; + epochsInRange.forEach((epochIndex) => { + if (epochs[epochIndex].properties.length > 0) { + if (visible && !filteredEpochs.columnVisibility.includes(epochIndex)) { + commentIndices.push(epochIndex); + } else if (!visible && filteredEpochs.columnVisibility.includes(epochIndex)) { + commentIndices = commentIndices.filter((value) => value !== epochIndex); + } + } + }); + setFilteredEpochs({ + plotVisibility: filteredEpochs.plotVisibility, + columnVisibility: commentIndices + }); + } /** * @@ -117,17 +116,17 @@ const EventManager = ({ */ const setEpochsInViewVisibility = (visible) => { if (epochsInRange.length < MAX_RENDERED_EPOCHS) { - epochsInRange.map((index) => { - if ((visible && !filteredEpochs.includes(index)) - || (!visible && filteredEpochs.includes(index))) { - toggleEpoch(index); + epochsInRange.forEach((epochIndex) => { + if ((visible && !filteredEpochs.plotVisibility.includes(epochIndex)) + || (!visible && filteredEpochs.plotVisibility.includes(epochIndex))) { + toggleEpoch(epochIndex); } }); } - }; + } const visibleEpochsInRange = epochsInRange.filter( - (epochIndex) => filteredEpochs.includes(epochIndex) + (epochIndex) => filteredEpochs.plotVisibility.includes(epochIndex) ); return ( @@ -142,13 +141,20 @@ const EventManager = ({ >

    - {`${epochType}s (${visibleEpochsInRange.length}/${epochsInRange.length})`} + {`Events (${visibleEpochsInRange.length}/${epochsInRange.length})`} -
    in timeline view [Total: {totalEpochs}] +
    in timeline view [Total: {epochs.length}]

    + setCommentsInRangeVisibility(!allCommentsVisible)} + > setEpochsInViewVisibility(!allEpochsVisible)} - - > - setAllCommentsVisible(!allCommentsVisible)} > } - {epochsInRange.map((index) => { - const epoch = epochs[index]; - const visible = filteredEpochs.includes(index); + {epochsInRange.map((epochIndex) => { + const epoch = epochs[epochIndex]; + const epochVisible = filteredEpochs.plotVisibility.includes(epochIndex); /** * */ const handleCommentVisibilityChange = () => { - if (!visibleComments.includes(index)) { - setVisibleComments([ - ...visibleComments, - index, - ]); - } else { - setVisibleComments(visibleComments.filter( - (value) => value !== index - )); - } + setFilteredEpochs({ + plotVisibility: filteredEpochs.plotVisibility, + columnVisibility: [ + ...filteredEpochs.columnVisibility, + epochIndex, + ] + }); }; /** @@ -231,64 +228,73 @@ const EventManager = ({ return (
    updateActiveEpoch(epochIndex)} + onMouseLeave={() => updateActiveEpoch(null)} >
    - {epoch.label}
    - {Math.round(epoch.onset * 1000) / 1000} - {epoch.duration > 0 - && ' - ' - + (Math.round((epoch.onset + epoch.duration) * 1000) / 1000) - } -
    -
    - {epoch.type === 'Annotation' && +
    + {epoch.label} +
    + {Math.round(epoch.onset * 1000) / 1000} + {epoch.duration > 0 + && ' - ' + + (Math.round((epoch.onset + epoch.duration) * 1000) / 1000) + } +
    +
    + {(epoch.properties.length > 0) && + + } - } - - {(epoch.comment || epoch.hed) && - - } + {epoch.type === 'Event' && + + } +
    - {visibleComments.includes(index) && + {epoch.properties.length > 0 &&
    - {epoch.type == 'Annotation' && epoch.comment && -

    Comment: {epoch.comment}

    - } - {epoch.type == 'Event' && epoch.hed && -

    HED: {epoch.hed}

    + {epoch.properties.length > 0 && +
    Additional Columns: + { + epoch.properties.map((property) => + `${property.PropertyName}: ${property.PropertyValue}` + ).join(', ') + } +
    }
    } @@ -304,7 +310,10 @@ const EventManager = ({ EventManager.defaultProps = { timeSelection: null, epochs: [], - filteredEpochs: [], + filteredEpochs: { + plotVisibility: [], + columnVisibility: [], + }, }; export default connect( diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Form.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Form.js index 882a3a2a55a..4ce71e7e8eb 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Form.js +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Form.js @@ -327,37 +327,39 @@ export const TextboxElement = (props) => { } props.onUserInput(props.id, value); }; + + const {disabled, required} = props; + let requiredHTML = required ? * : null; + let errorMessage = null; + let elementClass = 'row form-group'; + /** * Renders the React component. * * @return {JSX} - React markup for component. */ return ( - <> +
    {props.label && -
    ); }; TextboxElement.defaultProps = { diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesCursor.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesCursor.tsx index 89eb142b90f..9df9c957b39 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesCursor.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesCursor.tsx @@ -180,7 +180,7 @@ const SeriesCursor = ( * */ const EpochMarker = () => { - const visibleEpochs = getEpochsInRange(epochs, interval, 'Event'); + const visibleEpochs = getEpochsInRange(epochs, interval); if (visibleEpochs .filter((index) => { filteredEpochs.includes(index); @@ -310,6 +310,6 @@ export default connect( (state: RootState)=> ({ cursorPosition: state.cursor.cursorPosition, epochs: state.dataset.epochs, - filteredEpochs: state.dataset.filteredEpochs, + filteredEpochs: state.dataset.filteredEpochs.plotVisibility, }) )(SeriesCursor); diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesRenderer.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesRenderer.tsx index df80a7ec875..e0c5f50ed3a 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesRenderer.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesRenderer.tsx @@ -17,6 +17,7 @@ import { DEFAULT_MAX_CHANNELS, CHANNEL_DISPLAY_OPTIONS, SIGNAL_UNIT, + Vector2, DEFAULT_TIME_INTERVAL, STATIC_SERIES_RANGE, DEFAULT_VIEWER_HEIGHT, @@ -28,7 +29,7 @@ import LineChunk from './LineChunk'; import Epoch from './Epoch'; import SeriesCursor from './SeriesCursor'; import {setRightPanel} from '../store/state/rightPanel'; -import {setFilteredEpochs, setDatasetMetadata} from '../store/state/dataset'; +import {setDatasetMetadata} from '../store/state/dataset'; import {setOffsetIndex} from '../store/logic/pagination'; import IntervalSelect from './IntervalSelect'; import EventManager from './EventManager'; @@ -61,7 +62,6 @@ import { Channel, Epoch as EpochType, RightPanel, - AnnotationMetadata, } from '../store/types'; import {setCurrentAnnotation} from '../store/state/currentAnnotation'; import {setCursorInteraction} from '../store/logic/cursorInteraction'; @@ -103,9 +103,8 @@ type CProps = { setInterval: (_: [number, number]) => void, setCurrentAnnotation: (_: EpochType) => void, physioFileID: number, - annotationMetadata: AnnotationMetadata, hoveredChannels: number[], - setHoveredChannels: (_: number[]) => void, + setHoveredChannels: (_: number[]) => void, }; /** @@ -144,7 +143,6 @@ type CProps = { * @param root0.limit * @param root0.setCurrentAnnotation * @param root0.physioFileID - * @param root0.annotationMetadata * @param root0.hoveredChannels * @param root0.setHoveredChannels */ @@ -182,10 +180,34 @@ const SeriesRenderer: FunctionComponent = ({ limit, setCurrentAnnotation, physioFileID, - annotationMetadata, hoveredChannels, setHoveredChannels, }) => { + if (channels.length === 0) return null; + + const [ + numDisplayedChannels, + setNumDisplayedChannels, + ] = useState(DEFAULT_MAX_CHANNELS); + const [cursorEnabled, setCursorEnabled] = useState(false); + const toggleCursor = () => setCursorEnabled((value) => !value); + const [DCOffsetView, setDCOffsetView] = useState(true); + const toggleDCOffsetView = () => setDCOffsetView((value) => !value); + const [stackedView, setStackedView] = useState(false); + const toggleStackedView = () => setStackedView((value) => !value); + const [singleMode, setSingleMode] = useState(false); + const toggleSingleMode = () => setSingleMode((value) => !value); + const [showOverflow, setShowOverflow] = useState(false); + const toggleShowOverflow = () => setShowOverflow((value) => !value); + const [highPass, setHighPass] = useState('none'); + const [lowPass, setLowPass] = useState('none'); + const [refNode, setRefNode] = useState(null); + const [bounds, setBounds] = useState(null); + const getBounds = useCallback((domNode) => { + if (domNode) { + setRefNode(domNode); + } + }, []); const intervalChange = Math.pow( 10, @@ -262,18 +284,13 @@ const SeriesRenderer: FunctionComponent = ({ const viewerRef = useRef(null); const cursorRef = useRef(null); - // Memoized to singal which vars are to be read from - const memoizedCallback = useCallback(null, [ - offsetIndex, interval, limit, timeSelection, amplitudeScale, - ]); - useEffect(() => { // Keypress handler /** * * @param e */ const keybindHandler = (e) => { - if (cursorRef.current) { // Cursor is on page / focus + if (cursorRef.current) { // Cursor is on plot / focus if ([ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ].indexOf(e.code) > -1) { @@ -324,46 +341,42 @@ const SeriesRenderer: FunctionComponent = ({ toggleSingleMode(); } break; + case 'KeyC': + setRightPanel(null); + break; + // case 'KeyA': + // setRightPanel('annotationForm'); + // break; + case 'KeyZ': + zoomToSelection(); + break; + case 'KeyX': + zoomReset(); + break; + case 'Minus': + zoomOut(); + break; + case 'Equal': // This key combination is '+' + zoomIn(); + break; + case 'KeyN': // Lower amplitude scale + setAmplitudesScale(1.1); + break; + case 'KeyM': // Increase amplitude scale + setAmplitudesScale(0.9); + break; } } } - - // Generic keybinds that don't require focus - if (e.shiftKey) { - switch (e.code) { - case 'KeyC': - setRightPanel(null); - break; - case 'KeyA': - setRightPanel('annotationForm'); - break; - case 'KeyZ': - zoomToSelection(); - break; - case 'KeyX': - zoomReset(); - break; - case 'Minus': - zoomOut(); - break; - case 'Equal': // This key combination is '+' - zoomIn(); - break; - case 'KeyN': // Lower amplitude scale - setAmplitudesScale(1.1); - break; - case 'KeyM': // Increase amplitude scale - setAmplitudesScale(0.9); - break; - } - } }; window.addEventListener('keydown', keybindHandler); return function cleanUp() { // Prevent multiple listeners window.removeEventListener('keydown', keybindHandler); }; - }, [memoizedCallback]); + }, [ + offsetIndex, interval, limit, timeSelection, amplitudeScale, stackedView + ]); useEffect(() => { setViewerHeight(viewerHeight); @@ -419,30 +432,6 @@ const SeriesRenderer: FunctionComponent = ({ prevHoveredChannels.current = hoveredChannels; }, [hoveredChannels]); - const [ - numDisplayedChannels, - setNumDisplayedChannels, - ] = useState(DEFAULT_MAX_CHANNELS); - const [cursorEnabled, setCursorEnabled] = useState(false); - const toggleCursor = () => setCursorEnabled((value) => !value); - const [DCOffsetView, setDCOffsetView] = useState(true); - const toggleDCOffsetView = () => setDCOffsetView((value) => !value); - const [stackedView, setStackedView] = useState(false); - const toggleStackedView = () => setStackedView((value) => !value); - const [singleMode, setSingleMode] = useState(false); - const toggleSingleMode = () => setSingleMode((value) => !value); - const [showOverflow, setShowOverflow] = useState(false); - const toggleShowOverflow = () => setShowOverflow((value) => !value); - const [highPass, setHighPass] = useState('none'); - const [lowPass, setLowPass] = useState('none'); - const [refNode, setRefNode] = useState(null); - const [bounds, setBounds] = useState(null); - const getBounds = useCallback((domNode) => { - if (domNode) { - setRefNode(domNode); - } - }, []); - const topLeft = vec2.fromValues( -viewerWidth/2, viewerHeight/2 @@ -461,8 +450,8 @@ const SeriesRenderer: FunctionComponent = ({ vec2.scale(center, center, 1 / 2); const scales: [ - ScaleLinear, - ScaleLinear + ScaleLinear, + ScaleLinear ] = [ scaleLinear() .domain(interval) @@ -505,32 +494,24 @@ const SeriesRenderer: FunctionComponent = ({ ); }; - /** - * - */ const EpochsLayer = () => { - const epochType = rightPanel === 'eventList' - ? 'Event' - : rightPanel === 'annotationList' - ? 'Annotation' - : null - ; - const visibleEpochs = getEpochsInRange(epochs, interval, epochType); + const visibleEpochs = rightPanel ? getEpochsInRange(epochs, interval) : []; const minEpochWidth = (interval[1] - interval[0]) * MIN_EPOCH_WIDTH / DEFAULT_TIME_INTERVAL[1]; return ( { - visibleEpochs.length < MAX_RENDERED_EPOCHS && + visibleEpochs.length < MAX_RENDERED_EPOCHS && visibleEpochs.map((index) => { return filteredEpochs.includes(index) && ( = ({ } {timeSelection && = ({ { channelList.map((channel, i) => { - if (!channelMetadata[channel.index]) { - return null; - } - const subTopLeft = vec2.create(); - vec2.add( - subTopLeft, - topLeft, - vec2.fromValues( - 0, - stackedView && !singleMode - ? (numDisplayedChannels - 2) * + if (!channelMetadata[channel.index]) { + return null; + } + const subTopLeft = vec2.create(); + vec2.add( + subTopLeft, + topLeft, + vec2.fromValues( + 0, + stackedView && !singleMode + ? (numDisplayedChannels - 2) * diagonal[1] / (2 * numDisplayedChannels) - : (i * diagonal[1]) / numDisplayedChannels - ) - ); + : (i * diagonal[1]) / numDisplayedChannels + ) + ); - const subBottomRight = vec2.create(); - vec2.add( - subBottomRight, - topLeft, - vec2.fromValues( - diagonal[0], - stackedView && !singleMode - ? (numDisplayedChannels + 2) * + const subBottomRight = vec2.create(); + vec2.add( + subBottomRight, + topLeft, + vec2.fromValues( + diagonal[0], + stackedView && !singleMode + ? (numDisplayedChannels + 2) * diagonal[1] / (2 * numDisplayedChannels) - : ((i + 1) * diagonal[1]) / numDisplayedChannels - ) - ); - - const subDiagonal = vec2.create(); - vec2.sub(subDiagonal, subBottomRight, subTopLeft); + : ((i + 1) * diagonal[1]) / numDisplayedChannels + ) + ); - const axisEnd = vec2.create(); - vec2.add(axisEnd, subTopLeft, vec2.fromValues(0.1, subDiagonal[1])); + const subDiagonal = vec2.create(); + vec2.sub(subDiagonal, subBottomRight, subTopLeft); + + const axisEnd = vec2.create(); + vec2.add(axisEnd, subTopLeft, vec2.fromValues(0.1, subDiagonal[1])); + + return ( + channel.traces.map((trace, j) => { + const numChunks = trace.chunks.filter( + (chunk) => chunk.values.length > 0 + ).length; + + const valuesInView = trace.chunks.map((chunk) => { + let includedIndices = [0, chunk.values.length]; + if (chunk.interval[0] < interval[0]) { + const startIndex = chunk.values.length * + (interval[0] - chunk.interval[0]) / + (chunk.interval[1] - chunk.interval[0]); + includedIndices = [startIndex, includedIndices[1]]; + } + if (chunk.interval[1] > interval[1]) { + const endIndex = chunk.values.length * + (interval[1] - chunk.interval[0]) / + (chunk.interval[1] - chunk.interval[0]); + includedIndices = [includedIndices[0], endIndex]; + } + return chunk.values.slice( + includedIndices[0], includedIndices[1] + ); + }).flat(); - return ( - channel.traces.map((trace, j) => { - const numChunks = trace.chunks.filter( - (chunk) => chunk.values.length > 0 - ).length; - - const valuesInView = trace.chunks.map((chunk) => { - let includedIndices = [0, chunk.values.length]; - if (chunk.interval[0] < interval[0]) { - const startIndex = chunk.values.length * - (interval[0] - chunk.interval[0]) / - (chunk.interval[1] - chunk.interval[0]); - includedIndices = [startIndex, includedIndices[1]]; - } - if (chunk.interval[1] > interval[1]) { - const endIndex = chunk.values.length * - (interval[1] - chunk.interval[0]) / - (chunk.interval[1] - chunk.interval[0]); - includedIndices = [includedIndices[0], endIndex]; + if (valuesInView.length === 0) { + return; } - return chunk.values.slice( - includedIndices[0], includedIndices[1] - ); - }).flat(); - if (valuesInView.length === 0) { - return; - } - - const seriesRange: [number, number] = STATIC_SERIES_RANGE; - - const scales: [ - ScaleLinear, - ScaleLinear - ] = [ - scaleLinear() - .domain(interval) - .range([subTopLeft[0], subBottomRight[0]]), - scaleLinear() - .domain(seriesRange) - .range( - stackedView - ? [ - -viewerHeight / (2 * numDisplayedChannels), - viewerHeight / (2 * numDisplayedChannels), - ] - : [subTopLeft[1], subBottomRight[1]] - ), - ]; - - const scaleByAmplitude = scaleLinear() - .domain(seriesRange.map((x) => x * amplitudeScale)) - .range([-0.5, 0.5]); - - /** - * - * @param values - */ - const getScaledMean = (values) => { - let numValues = values.length; - return values.reduce((a, b) => { + const seriesRange: [number, number] = STATIC_SERIES_RANGE; + + const scales: [ + ScaleLinear, + ScaleLinear + ] = [ + scaleLinear() + .domain(interval) + .range([subTopLeft[0], subBottomRight[0]]), + scaleLinear() + .domain(seriesRange) + .range( + stackedView + ? [ + -viewerHeight / (2 * numDisplayedChannels), + viewerHeight / (2 * numDisplayedChannels), + ] + : [subTopLeft[1], subBottomRight[1]] + ), + ]; + + const scaleByAmplitude = scaleLinear() + .domain(seriesRange.map((x) => x * amplitudeScale)) + .range([-0.5, 0.5]); + + /** + * + * @param values + */ + const getScaledMean = (values) => { + let numValues = values.length; + return values.reduce((a, b) => { if (isNaN(b)) { numValues--; return a; } - return a + scaleByAmplitude(b); - }, 0) / numValues; - }; + return a + scaleByAmplitude(b); + }, 0) / numValues; + }; - const DCOffset = DCOffsetView - ? getScaledMean(valuesInView) - : 0; + const DCOffset = DCOffsetView + ? getScaledMean(valuesInView) + : 0; - return ( - trace.chunks.map((chunk, k, chunks) => ( + return ( + trace.chunks.map((chunk, k, chunks) => ( = ({ : chunks[k - 1].values.slice(-1)[0] } /> - )) - ); - }) - ); - })} + )) + ); + }) + ); + })} ); }; @@ -811,23 +792,6 @@ const SeriesRenderer: FunctionComponent = ({ ); }; - const updateCursorCallback = useCallback((cursor: [number, number]) => { - setCursor({ - cursorPosition: [cursor[0], cursor[1]], - viewerRef: viewerRef, - }); - }, []); - - /** - * - * @param v - */ - const updateTimeSelectionCallback = useCallback((v: vec2) => { - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - R.compose(dragStart, R.nth(0))(v); - }, [bounds]); - /** * * @param channelIndex @@ -895,7 +859,7 @@ const SeriesRenderer: FunctionComponent = ({ className='btn btn-primary btn-xs btn-zoom' onClick={zoomToSelection} disabled={!selectionCanBeZoomedTo} - value='Region' + value='Fit to Window' />
    @@ -1005,21 +969,18 @@ const SeriesRenderer: FunctionComponent = ({ )}
    - +
    = ({
    +
    +
    +
    +
    = ({ {filteredChannels .slice(0, numDisplayedChannels) .map((channel) => ( -
    onChannelHover(channel.index)} - onMouseLeave={() => onChannelHover(-1)} - > - {channelMetadata[channel.index] && - channelMetadata[channel.index].name} -
    - ))} + ? 'bold' + : 'normal'}`, + }} + onMouseEnter={() => onChannelHover(channel.index)} + onMouseLeave={() => onChannelHover(-1)} + > + {channelMetadata[channel.index] && + channelMetadata[channel.index].name} +
    + ))}
    = ({ { + setCursor({ + cursorPosition: [cursor[0], cursor[1]], + viewerRef: viewerRef + }); + }, [])} + mouseDown={useCallback((v: Vector2) => { + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + R.compose(dragStart, R.nth(0))(v); + }, [bounds])} showOverflow={showOverflow} chunksURL={chunksURL} > @@ -1262,58 +1240,15 @@ const SeriesRenderer: FunctionComponent = ({ } } - { - [...Array(epochs.length).keys()].filter((i) => - epochs[i].type === 'Annotation' - ).length > 0 && - - } - { - - }
    {rightPanel &&
    {rightPanel === 'annotationForm' && - + } - {rightPanel === 'eventList' && } - {rightPanel === 'annotationList' && } + {rightPanel === 'eventList' && }
    }
    @@ -1349,7 +1284,7 @@ export default connect( chunksURL: state.dataset.chunksURL, channels: state.channels, epochs: state.dataset.epochs, - filteredEpochs: state.dataset.filteredEpochs, + filteredEpochs: state.dataset.filteredEpochs.plotVisibility, activeEpoch: state.dataset.activeEpoch, hidden: state.montage.hidden, channelMetadata: state.dataset.channelMetadata, @@ -1400,10 +1335,6 @@ export default connect( dispatch, setViewerHeight ), - setFilteredEpochs: R.compose( - dispatch, - setFilteredEpochs - ), setDatasetMetadata: R.compose( dispatch, setDatasetMetadata diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/filterEpochs.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/filterEpochs.tsx index 55126816403..a71937c0a24 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/filterEpochs.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/filterEpochs.tsx @@ -70,10 +70,12 @@ export const createToggleEpochEpic = (fromState: (_: any) => any) => ( const index = payload; let newFilteredEpochs; - if (filteredEpochs.includes(index)) { - newFilteredEpochs = filteredEpochs.filter((i) => i !== index); + if (filteredEpochs.plotVisibility.includes(index)) { + newFilteredEpochs = filteredEpochs.plotVisibility.filter( + (i) => i !== index + ); } else if (index >= 0 && index < epochs.length) { - newFilteredEpochs = filteredEpochs.slice(); + newFilteredEpochs = filteredEpochs.plotVisibility.slice(); newFilteredEpochs.push(index); newFilteredEpochs.sort(); } else { @@ -81,7 +83,10 @@ export const createToggleEpochEpic = (fromState: (_: any) => any) => ( } return (dispatch) => { - dispatch(setFilteredEpochs(newFilteredEpochs)); + dispatch(setFilteredEpochs({ + plotVisibility: newFilteredEpochs, + columnVisibility: filteredEpochs.columnVisibility, + })); }; }) ); @@ -121,16 +126,9 @@ export const createActiveEpochEpic = (fromState: (_: any) => any) => ( * * @param {Epoch[]} epochs - Array of epoch * @param {[number, number]} interval - Time interval to search - * @param {string} epochType - Epoch type (Annotation|Event) - * @param {boolean} withComments - Include only if has comments * @returns {Epoch[]} - Epoch[] in interval with epochType */ -export const getEpochsInRange = ( - epochs, - interval, - epochType, - withComments = false, -) => { +export const getEpochsInRange = (epochs, interval) => { return [...Array(epochs.length).keys()].filter((index) => ( (isNaN(epochs[index].onset) && interval[0] === 0) @@ -139,8 +137,6 @@ export const getEpochsInRange = ( epochs[index].onset + epochs[index].duration > interval[0] && epochs[index].onset < interval[1] ) - ) && - epochs[index].type === epochType && - (!withComments || epochs[index].hed || epochs[index].comment) + ) ); }; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.tsx index 35472df7a54..0844429e38a 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.tsx @@ -1,6 +1,6 @@ import * as R from 'ramda'; import {createAction} from 'redux-actions'; -import {ChannelMetadata, Epoch} from '../types'; +import {ChannelMetadata, Epoch, EpochFilter} from '../types'; import {DEFAULT_MAX_CHANNELS} from '../../../vector'; export const SET_EPOCHS = 'SET_EPOCHS'; @@ -45,7 +45,7 @@ export type State = { limit: number, samplingFrequency: string, epochs: Epoch[], - filteredEpochs: number[], + filteredEpochs: EpochFilter, activeEpoch: number | null, physioFileID: number | null, shapes: number[][], @@ -66,7 +66,10 @@ export const datasetReducer = ( chunksURL: '', channelMetadata: [], epochs: [], - filteredEpochs: [], + filteredEpochs: { + plotVisibility: [], + columnVisibility: [], + }, activeEpoch: null, physioFileID: null, offsetIndex: 1, diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/montage.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/montage.tsx index 13d2f799486..c6000c69130 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/montage.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/montage.tsx @@ -1,6 +1,6 @@ import * as R from 'ramda'; import {createAction} from 'redux-actions'; -import {Electrode} from '../types'; +import {CoordinateSystem, Electrode} from '../types'; export const SET_ELECTRODES = 'SET_ELECTRODES'; export const setElectrodes = createAction(SET_ELECTRODES); @@ -8,13 +8,18 @@ export const setElectrodes = createAction(SET_ELECTRODES); export const SET_HIDDEN = 'SET_HIDDEN'; export const setHidden = createAction(SET_HIDDEN); +export const SET_COORDINATE_SYSTEM = 'SET_COORDINATE_SYSTEM'; +export const setCoordinateSystem = createAction(SET_COORDINATE_SYSTEM); + export type Action = | {type: 'SET_ELECTRODES', payload: Electrode[]} - | {type: 'SET_HIDDEN', payload: number[]}; + | {type: 'SET_HIDDEN', payload: number[]} + | {type: 'SET_COORDINATE_SYSTEM', payload: CoordinateSystem}; export type State = { electrodes: Electrode[], - hidden: number[] + hidden: number[], + coordinateSystem: CoordinateSystem, }; export type Reducer = (state: State, action?: Action) => State; @@ -27,7 +32,7 @@ export type Reducer = (state: State, action?: Action) => State; * @returns {State} - The updated state */ export const montageReducer: Reducer = ( - state = {electrodes: [], hidden: []}, + state = {electrodes: [], hidden: [], coordinateSystem: null}, action ) => { if (!action) { @@ -40,6 +45,9 @@ export const montageReducer: Reducer = ( case SET_HIDDEN: { return R.assoc('hidden', action.payload, state); } + case SET_COORDINATE_SYSTEM: { + return R.assoc('coordinateSystem', action.payload, state); + } default: { return state; } diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.tsx index cd826e781ee..1e5a7829270 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.tsx @@ -28,30 +28,38 @@ export type Channel = { export type Epoch = { onset: number, duration: number, - type: 'Event' | 'Annotation', + type: 'Event', label: string, - comment?: string, + value: string, + trialType: string, + properties?: any[], hed?: string, channels: number[] | 'all', - annotationInstanceID?: number, + physiologicalTaskEventID?: number, }; -export type EventMetadata = { - instances: any[], +export type EpochFilter = { + plotVisibility: number[], + columnVisibility: number[], } -export type AnnotationMetadata = { +export type EventMetadata = { instances: any[], - labels: any[], - metadata: any[] + extraColumns: any[], } export type RightPanel = 'annotationForm' | 'eventList' - | 'annotationList' | null; + +export type CoordinateSystem = { + name: string | 'Other', + units: string | 'm', + description: string | 'n/a' +}; + export type Electrode = { name: string, channelIndex?: number, diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/vector/index.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/vector/index.tsx index 994fc806fe3..b050a2e9258 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/vector/index.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/vector/index.tsx @@ -1,5 +1,7 @@ import {vec2, glMatrix} from 'gl-matrix'; +export type Vector2 = typeof glMatrix.ARRAY_TYPE; + /** * Apply transformation f on point p * diff --git a/modules/electrophysiology_browser/php/annotations.class.inc b/modules/electrophysiology_browser/php/events.class.inc similarity index 61% rename from modules/electrophysiology_browser/php/annotations.class.inc rename to modules/electrophysiology_browser/php/events.class.inc index 0b0ea96aac4..a32360dca3c 100644 --- a/modules/electrophysiology_browser/php/annotations.class.inc +++ b/modules/electrophysiology_browser/php/events.class.inc @@ -1,11 +1,13 @@ getMethod()) { case 'GET': + // TODO: Get official server-side solution + Add to documentation + // set_time_limit(300); // Increase request time limit to 5 minutes + // ini_set('memory_limit', '1G'); // Increase memory allocation limit + $parameters = $request->getQueryParams(); $sessionID = $db->pselectOne( 'SELECT SessionID @@ -59,7 +65,7 @@ class Annotations extends \NDB_Page } $physioFileID = intval($parameters['physioFileID']); - (new ElectrophysioAnnotations($physioFileID))->updateFiles(); + (new ElectrophysioEvents($physioFileID))->updateFiles(); $config = \NDB_Factory::singleton()->config(); $downloadpath = \Utility::appendForwardSlash( @@ -73,12 +79,16 @@ class Annotations extends \NDB_Page $downloader = new \LORIS\FilesDownloadHandler( new \SPLFileInfo($downloadpath . $path) ); + return $downloader->handle( $request->withAttribute('filename', $filename) ); case 'DELETE': - $parameters = json_decode((string) $request->getBody(), true); - if (!$user->hasPermission('electrophysiology_browser_edit_annotations') + $parameters = json_decode((string)$request->getBody(), true); + + if (!$user->hasPermission( + 'electrophysiology_browser_edit_annotations' + ) ) { return (new \LORIS\Http\Response\JSON\Unauthorized()); } @@ -89,53 +99,50 @@ class Annotations extends \NDB_Page return (new \LORIS\Http\Response\JSON\BadRequest()); } - (new ElectrophysioAnnotations(intval($parameters['physioFileID']))) - ->delete(intval($parameters['instance_id'])); - return (new \LORIS\Http\Response\JSON\OK()); case 'POST': - $parameters = json_decode((string) $request->getBody(), true); - if (!$user->hasPermission('electrophysiology_browser_edit_annotations') + // TODO: Better failure reporting + $parameters = json_decode((string)$request->getBody(), true); + + if (!$user->hasPermission( + 'electrophysiology_browser_edit_annotations' + ) ) { return (new \LORIS\Http\Response\JSON\Unauthorized()); } - if (!isset($parameters['physioFileID'])) { + if (!isset($parameters['physioFileID']) + || !isset($parameters['request_type']) + ) { return (new \LORIS\Http\Response\JSON\BadRequest()); } - $instance_data = $parameters['instance']; - // $metadata = $parameters['metadata']; - // TODO: Figure out a better description modeled on other derivatives - $metadata = [ - 'description' => 'An annotation', - 'sources' => 'EEGNet LORIS', - 'author' => $user->getFullname() - ]; - - $instance_id = $parameters['instance_id'] ? - intval($parameters['instance_id']) : null; - $parameter_id = $parameters['parameter_id'] ?? null; - - (new ElectrophysioAnnotations(intval($parameters['physioFileID']))) - ->update($instance_data, $metadata, $instance_id, $parameter_id); - - // if new annotation, get instanceID - if (is_null($instance_id)) { - $instance_id = $db->pselectOne( - "SELECT MAX(AnnotationInstanceID) - FROM physiological_annotation_instance ai - JOIN physiological_annotation_file af USING (AnnotationFileID) - WHERE PhysiologicalFileID=:physioFileID - ", - ['physioFileID' => $parameters['physioFileID']] - ); + switch ($parameters['request_type']) { + case 'event_update': + $instance_data = $parameters['instance']; + // $metadata = $parameters['metadata']; + // TODO: Figure out better description modeled on other derivatives + $metadata = [ + 'description' => 'An event', + 'sources' => 'EEGNet LORIS', + 'author' => $user->getFullname() + ]; + + $instance_id = $parameters['instance_id'] ? + intval($parameters['instance_id']) : null; + + $updated_instance = ( + new ElectrophysioEvents(intval($parameters['physioFileID'])) + )->update($instance_data, $metadata, $instance_id); + + if (count($updated_instance) > 0) { + return (new \LORIS\Http\Response\JSON\OK( + ['instance' => $updated_instance] + )); + } + return (new \LORIS\Http\Response\JSON\Unauthorized()); } - - return (new \LORIS\Http\Response\JSON\OK( - ['instance_id' => $instance_id] - )); - default: + default: return (new \LORIS\Http\Response\JSON\MethodNotAllowed( ["GET", "DELETE", "POST"] )); diff --git a/modules/electrophysiology_browser/php/models/electrophysioannotations.class.inc b/modules/electrophysiology_browser/php/models/electrophysioannotations.class.inc deleted file mode 100644 index 82e23adadf4..00000000000 --- a/modules/electrophysiology_browser/php/models/electrophysioannotations.class.inc +++ /dev/null @@ -1,607 +0,0 @@ - - * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 - * @link https://www.github.com/aces/Loris/ - */ -class ElectrophysioAnnotations -{ - private int $_physioFileID; - private array $_data; - - /** - * Construct an Annotation object - * - * @param integer $physioFileID Electrophysiological file ID - * to collect annotation data from - */ - function __construct(int $physioFileID) - { - $this->_physioFileID = $physioFileID; - $db = \NDB_Factory::singleton()->database(); - - $annotationInstance = $db->pselect( - 'SELECT i.* - FROM physiological_annotation_instance AS i - JOIN physiological_annotation_file AS f - ON f.AnnotationFileID = i.AnnotationFileID - WHERE f.PhysiologicalFileID=:PFID AND f.FileType="tsv"', - ['PFID' => $this->_physioFileID] - ); - - $annotationMetadata = $db->pselect( - 'SELECT p.* - FROM physiological_annotation_parameter AS p - JOIN physiological_annotation_file AS f - ON f.AnnotationFileID = p.AnnotationFileID - WHERE f.PhysiologicalFileID=:PFID AND f.FileType="json"', - ['PFID' => $this->_physioFileID] - ); - - $annotationLabels = $db->pselect( - 'SELECT * FROM physiological_annotation_label', - [] - ); - - $this->_data = [ - 'instances' => $annotationInstance, - 'metadata' => $annotationMetadata, - 'labels' => $annotationLabels, - ]; - } - - /** - * Get data for the Electrophysiological file annotations - * - * @return array The data array - */ - function getData(): array - { - return $this->_data; - } - - /** - * Updates annotation tables when there is a POST request. - * Will add new derivative files if none exist for the given instance. - * Will either add new annotations or update existing ones. - * - * @param array $instance_data Instance data - * @param array $metadata Metadata - * @param int|null $instance_id InstanceID - * @param int|null $parameter_id ParameterID - * - * @return void - */ - function update( - array $instance_data, - array $metadata, - ?int $instance_id, - ?int $parameter_id - ): void { - - $factory = \NDB_Factory::singleton(); - $user = $factory->user(); - $db = $factory->database(); - - if ($user->hasPermission('electrophysiology_browser_edit_annotations')) { - - //If the label is new, add to annotation label table - //and get label ID - $labelID = $db->pselectOne( - // Adding MAX here as a hack fix for now until LORIS-MRI - // bugfix for issue https://github.com/aces/Loris-MRI/issues/763 - // is available and cleanup happens of the annotation_label table - "SELECT MAX(AnnotationLabelID) - FROM physiological_annotation_label - WHERE LabelName=:label", - ['label' => $instance_data['label_name']] - ); - if (empty($labelID)) { - $data = [ - 'LabelName' => $instance_data['label_name'], - 'LabelDescription' => $instance_data['label_description'] - ]; - $db->insert("physiological_annotation_label", $data); - $labelID = $db->pselectOne( - "SELECT AnnotationLabelID - FROM physiological_annotation_label - WHERE LabelName=:label", - ['label' => $instance_data['label_name']] - ); - } - - //If no derivative files exist, must create new files - $annotationFIDs = $db->pselect( - "SELECT AnnotationFileID - FROM physiological_annotation_file - WHERE PhysiologicalFileID=:PFID", - ['PFID' => $this->_physioFileID] - ); - - //Get data from POST request - $metadata = [ - 'Description' => $metadata['description'], - 'Sources' => $metadata['sources'], - 'Author' => $metadata['author'] - ]; - - $instance = [ - 'Onset' => $instance_data['onset'], - 'Duration' => $instance_data['duration'], - 'AnnotationLabelID' => $labelID, - 'Channels' => $instance_data['channels'] === 'all' ? - null : - $instance_data['channels'], - 'Description' => $instance_data['description'] - ]; - - //Insert new files and data into DB - if (empty($annotationFIDs)) { - //Create new annotation files - $this->_createFiles(); - - //Get new annotation file ID - $annotation_tsv_ID = $db->pselectOne( - "SELECT AnnotationFileID - FROM physiological_annotation_file - WHERE PhysiologicalFileID=:PFID - AND FileType='tsv'", - ['PFID' => $this->_physioFileID] - ); - //Get new annotation file ID - $annotation_json_ID = $db->pselectOne( - "SELECT AnnotationFileID - FROM physiological_annotation_file - WHERE PhysiologicalFileID=:PFID - AND FileType='json'", - ['PFID' => $this->_physioFileID] - ); - - $metadata['AnnotationFileID'] = $annotation_json_ID; - $db->insert("physiological_annotation_parameter", $metadata); - - //Get new metadata file ID - $metadata_ID = $db->pselectOne( - "SELECT AnnotationParameterID - FROM physiological_annotation_parameter - WHERE AnnotationFileID=:annotation_ID", - ['annotation_ID' => $annotation_json_ID] - ); - - $instance['AnnotationFileID'] = $annotation_tsv_ID; - $instance['AnnotationParameterID'] = $metadata_ID; - $db->insert("physiological_annotation_instance", $instance); - - } else { - //If the files are not new - //Get annotation file ID for the tsv file - $tsv_ID = $db->pselectOne( - "SELECT AnnotationFileID - FROM physiological_annotation_file - WHERE PhysiologicalFileID=:PFID - AND FileType='tsv'", - ['PFID' => $this->_physioFileID] - ); - //Get annotation file ID for the json file - $json_ID = $db->pselectOne( - "SELECT AnnotationFileID - FROM physiological_annotation_file - WHERE PhysiologicalFileID=:PFID - AND FileType='json'", - ['PFID' => $this->_physioFileID] - ); - - $instance['AnnotationFileID'] = $tsv_ID; - $metadata['AnnotationFileID'] = $json_ID; - - /* If no instance ID is specified, insert new instance - * into instance table and get the parameter file ID - * from the parameter table - */ - if (is_null($instance_id)) { - $parameterID = $db->pselectOne( - "SELECT AnnotationParameterID - FROM physiological_annotation_parameter - WHERE AnnotationFileID=:annotationFID", - ['annotationFID' => $json_ID] - ); - $instance['AnnotationParameterID'] = $parameterID; - - $db->insert('physiological_annotation_instance', $instance); - } else { - $db->update( - 'physiological_annotation_instance', - $instance, - ['AnnotationInstanceID' => $instance_id] - ); - } - //Update parameter table if parameter ID provided - if (!is_null($parameter_id)) { - $db->update( - 'physiological_annotation_parameter', - $metadata, - ['AnnotationParameterID' => $parameter_id] - ); - } - - //In all cases where files are not new, - //set LastUpdate time for all related files - - $db->update( - 'physiological_annotation_file', - ['LastUpdate' => date("Y-m-d H:i:s")], - ['PhysiologicalFileID' => $this->_physioFileID] - ); - } - } - } - - /** - * Deletes one annotation - * - * @param int $annotationID Annotation ID - * - * @return void - */ - function delete(int $annotationID): void - { - // TODO : check that $annotationID belongs to physioFileID - $db = \NDB_Factory::singleton()->database(); - - $physioFileID = $db->pselectone( - 'SELECT PhysiologicalFileID - FROM physiological_annotation_file AS f - INNER JOIN physiological_annotation_instance AS i - ON f.AnnotationFileID=i.AnnotationFileID - AND i.AnnotationInstanceID=:annotationID', - ['annotationID' => $annotationID] - ); - - if ($this->_physioFileID == $physioFileID) { - $db->delete( - "physiological_annotation_instance", - ['AnnotationInstanceID' => $annotationID] - ); - } - } - - /** - * Updates the derivative files associated with the - * physiological file ID - * - * @return void - * @throws SodiumException - */ - function updateFiles(): void - { - $db = \NDB_Factory::singleton()->database(); - - //If no derivative files exist, must create new files - $annotationFIDs = $db->pselect( - "SELECT AnnotationFileID - FROM physiological_annotation_file - WHERE PhysiologicalFileID=:PFID", - ['PFID' => $this->_physioFileID] - ); - //Insert new files and data into DB - if (empty($annotationFIDs)) { - //Create new annotation files - $this->_createFiles(); - } - - //Get data directory base path from Config - $dataDir = $db->pselectOne( - 'SELECT Value - FROM Config AS config - INNER JOIN ConfigSettings AS c - ON c.Name=:name AND config.ConfigID=c.ID', - ['name' => 'dataDirBasepath'] - ); - - $tsv_entries = [ - 'onset', 'duration', 'label', 'channels', 'absolute_time', 'description' - ]; - - $tsv = $db->pselect( - "SELECT - AnnotationFileID AS id, - FilePath AS filePath, - LastUpdate AS lastUpdate, - LastWritten AS lastWritten - FROM physiological_annotation_file - WHERE PhysiologicalFileID=:PFID - AND FileType='tsv'", - ['PFID' => $this->_physioFileID] - ); - - $json = $db->pselect( - "SELECT - AnnotationFileID AS id, - FilePath AS filePath, - LastUpdate AS lastUpdate, - LastWritten AS lastWritten - FROM physiological_annotation_file - WHERE PhysiologicalFileID=:PFID - AND FileType='json'", - ['PFID' => $this->_physioFileID] - ); - - $tsv_path = $dataDir.$tsv[0]['filePath']; - $json_path = $dataDir.$json[0]['filePath']; - - //Update files if files updated before database updated - if ($tsv[0]['lastWritten'] <= $tsv[0]['lastUpdate'] - || $json[0]['lastWritten'] <= $json[0]['lastUpdate'] - ) { - //Update the three files with the given paths - $labels = []; // Label Name => Label Description - $tsv_file = fopen($tsv_path, 'w'); //Will override all file content - - //Get all annotation instances - //Then go thru each and get the label name + description - //add label name to file and also to an array for json file - //change anything null to n/a - $instances = $db->pselect( - "SELECT - p.Onset AS Onset, - p.Duration AS Duration, - l.LabelName AS LabelName, - l.LabelDescription AS LabelDescription, - p.Channels AS Channels, - p.AbsoluteTime AS AbsoluteTime, - p.Description AS Description - FROM physiological_annotation_instance p - LEFT JOIN physiological_annotation_label l - ON (l.AnnotationLabelID=p.AnnotationLabelID) - WHERE p.AnnotationFileID=:AFID", - ['AFID' => $tsv[0]['id']] - ); - - if (count($instances) < 1) { - return; - } - - //Add columns - $columns = implode("\t", $tsv_entries); - fwrite($tsv_file, $columns."\n"); - - foreach ($instances as $instance) { - //Add labels to list for parameter file - $labels[$instance['LabelName']] = $instance['LabelDescription']; - - //Setup each column in correct order - $input_tsv = [ - $instance['Onset'], - $instance['Duration'], - $instance['LabelName'], - $instance['Channels'], - $instance['AbsoluteTime'], - $instance['Description'] - ]; - //Set all null values to 'n/a' - $input_tsv = array_map( - function ($v) { - return (is_null($v)) ? "n/a" : $v; - }, - $input_tsv - ); - //Implode with tabs as delimeter - $input = implode("\t", $input_tsv); - - fwrite($tsv_file, $input."\n"); - } - fclose($tsv_file); - - //Write to metadata (json) file - //Get metadata from database (should only be 1 entry) - $json_desc = $db->pselectOne( - "SELECT Description - FROM physiological_annotation_parameter - WHERE AnnotationFileID=:AFID", - ['AFID' => $json[0]['id']] - ); - $json_source = $db->pselectOne( - "SELECT Sources - FROM physiological_annotation_parameter - WHERE AnnotationFileID=:AFID", - ['AFID' => $json[0]['id']] - ); - $json_author = $db->pselectOne( - "SELECT Author - FROM physiological_annotation_parameter - WHERE AnnotationFileID=:AFID", - ['AFID' => $json[0]['id']] - ); - //Get "IntendedFor" entry: physiological file path - $physioFilePath = $db->pselectOne( - "SELECT FilePath - FROM physiological_file - WHERE PhysiologicalFileID=:PFID", - ['PFID' => $this->_physioFileID] - ); - - $input_json = [ - "Description" => $json_desc, - "IntendedFor" => $physioFilePath, - "Sources" => $json_source, - "Author" => $json_author, - "LabelDescription" => $labels - ]; - $input_encode = json_encode($input_json, JSON_PRETTY_PRINT); - - $json_file = fopen($json_path, 'w'); - fwrite($json_file, $input_encode); - fclose($json_file); - - //Update archives and create new hash - $this->_updateArchives([$tsv_path, $json_path]); - - //Update time that files were written to - $db->update( - 'physiological_annotation_file', - ['LastWritten' => date("Y-m-d H:i:s")], - ['PhysiologicalFileID' => $this->_physioFileID] - ); - } - } - - /** - * Creates new annotation files for the given physiological file - * and inserts their information into database - * - * @return void - * @throws SodiumException - */ - function _createFiles() : void - { - $db = \NDB_Factory::singleton()->database(); - - $physioFilePath = $db->pselectOne( - 'SELECT FilePath - FROM physiological_file - WHERE PhysiologicalFileID=:PFID', - ['PFID' => $this->_physioFileID] - ); - - // Get output type (raw, derivative) - $outputType = $db->pselectOne( - 'SELECT OutputTypeName - FROM physiological_file pf - JOIN physiological_output_type ot USING (PhysiologicalOutputTypeID) - WHERE PhysiologicalFileID=:PFID', - ['PFID' => $this->_physioFileID] - ); - - //Create new filepaths - //Get data directory base path from Config - $dataDir = $db->pselectOne( - 'SELECT Value - FROM Config AS config - INNER JOIN ConfigSettings AS c - ON c.Name=:name AND config.ConfigID=c.ID', - ['name' => 'dataDirBasepath'] - ); - //Create path with correct structure - $subPath = strstr($physioFilePath, "sub"); - - if ($outputType === 'derivative') { - $destinationPath = $dataDir - . "bids_imports/derivatives/loris_annotations/" - . $subPath; - } else { - $destinationPath = $dataDir . $physioFilePath; - } - - //Create directories if they don't exist - $dirname = pathinfo($destinationPath, PATHINFO_DIRNAME); - if (!file_exists($dirname)) { - mkdir($dirname, 0777, true); - } - - //Replace file type with "annotations" - $pathWithoutEDF = substr( - $destinationPath, - 0, - strrpos($destinationPath, "_") - ); - - $tsv_path = $pathWithoutEDF . "_annotations.tsv"; - $json_path = $pathWithoutEDF . "_annotations.json"; - $tgz_path = $pathWithoutEDF . "_annotations.tgz"; - - //Create files - $tsv_file = fopen($tsv_path, 'a+'); - $json_file = fopen($json_path, 'a+'); - - $tgz_file = new \PharData($tgz_path); - $tgz_file->addFile($tsv_path, basename($tsv_path)); - $tgz_file->addFile($json_path, basename($json_path)); - fclose($tsv_file); - fclose($json_file); - - $annotation_f = file_get_contents($tgz_path); - $annotation_hash = sodium_crypto_generichash($annotation_f); - - $params_tsv = [ - 'PhysiologicalFileID' => $this->_physioFileID, - 'FileType' => 'tsv', - 'FilePath' => str_replace($dataDir, '', $tsv_path) - ]; - $params_json = [ - 'PhysiologicalFileID' => $this->_physioFileID, - 'FileType' => 'json', - 'FilePath' => str_replace($dataDir, '', $json_path), - ]; - $params_archive = [ - 'PhysiologicalFileID' => $this->_physioFileID, - 'FilePath' => str_replace($dataDir, '', $tgz_path), - 'Blake2bHash' => bin2hex($annotation_hash) - ]; - $db->insert("physiological_annotation_file", $params_tsv); - $db->insert("physiological_annotation_file", $params_json); - $db->insert("physiological_annotation_archive", $params_archive); - } - - /** - * Updates the annotation and physiological archives for the given - * physiological file ID with the provided paths and updates - * database with new archive file hash - * - * @param array $paths Paths to files to be added to archive - * - * @return void - * @throws SodiumException - */ - function _updateArchives(array $paths) : void - { - $db = \NDB_Factory::singleton()->database(); - - $dataDir = $db->pselectOne( - 'SELECT Value - FROM Config AS config - INNER JOIN ConfigSettings AS c - ON c.Name=:name AND config.ConfigID=c.ID', - ['name' => 'dataDirBasepath'] - ); - $queries = [ - 'physiological_annotation_archive', - 'physiological_archive' - ]; - - foreach ($queries as $query) { - $filepath = $db->pselectone( - "SELECT - DISTINCT(FilePath) - FROM $query - WHERE PhysiologicalFileID=:PFID", - ['PFID' => $this->_physioFileID] - ); - if (!$filepath) { - continue; - } - $filepath = $dataDir.$filepath; - $arch_file = new \PharData($filepath); - foreach ($paths as $path) { - $arch_file->addFile($path, basename($path)); - } - - $f = file_get_contents($filepath); - $hash = sodium_crypto_generichash($f); - - //Update database with hash - $db->update( - $query, - ['Blake2bHash' => bin2hex($hash)], - ['PhysiologicalFileID' => $this->_physioFileID] - ); - } - } -} diff --git a/modules/electrophysiology_browser/php/models/electrophysioevents.class.inc b/modules/electrophysiology_browser/php/models/electrophysioevents.class.inc index 25161a93ed3..bf1f5a15db4 100644 --- a/modules/electrophysiology_browser/php/models/electrophysioevents.class.inc +++ b/modules/electrophysiology_browser/php/models/electrophysioevents.class.inc @@ -1,5 +1,6 @@ database(); $taskEvents = $db->pselect( - 'SELECT te.* + 'SELECT te.* FROM physiological_task_event AS te JOIN physiological_event_file AS f - ON f.EventFileID = te.EventFileID + ON f.EventFileID = te.EventFileID WHERE f.PhysiologicalFileID=:PFID AND f.FileType="tsv"', ['PFID' => $this->_physioFileID] ); - /** - * TODO: Get event params and metadata. - * NOT in the scope of current task - **/ + $taskEventIDs = array_map( + function ($taskEvent) { + return $taskEvent['PhysiologicalTaskEventID']; + }, + $taskEvents + ); + + $taskEventIDs = array_combine( + array_map('intval', array_keys($taskEventIDs)), + array_values($taskEventIDs) + ); + + $extraColumns = $db->pselect( + 'SELECT opt.* + FROM physiological_task_event_opt AS opt + WHERE opt.PhysiologicalTaskEventID IN (' + . ( + count($taskEventIDs) > 0 + ? join(',', $taskEventIDs) + : 'null' + ) . ')', + [] + ); $this->_data = [ - 'instances' => $taskEvents, + 'instances' => $taskEvents, + 'extraColumns' => $extraColumns, ]; } /** - * Get data for the Electrophysiological file annotations + * Get data for the Electrophysiological events * * @return array The data array */ @@ -57,7 +78,328 @@ class ElectrophysioEvents } /** - * TODO: Add other features such as add, update, delete - * NOT in the scope of current task - **/ + * Updates event tables when there is a POST request. + * Will add new derivative files if none exist for the given instance. + * Will either add new events or update existing ones. + * + * @param array $instance_data Instance data + * @param array $metadata Metadata + * @param int|null $instance_id InstanceID + * + * @return array + */ + function update( + array $instance_data, + array $metadata, + ?int $instance_id, + ): array { + + $factory = \NDB_Factory::singleton(); + $user = $factory->user(); + $db = $factory->database(); + + if ($user->hasPermission('electrophysiology_browser_edit_annotations')) { + + //If no derivative files exist, must create new files + $eventFileID = $db->pselect( + "SELECT EventFileID + FROM physiological_task_event + WHERE PhysiologicalFileID=:PFID", + ['PFID' => $this->_physioFileID] + ); + + if (is_null($instance_id)) { + // TODO: Support Instance INSERT + return []; + } + + $instance = [ + 'Onset' => $instance_data['onset'], + 'Duration' => $instance_data['duration'], + ]; + + // TODO: Support Event Instance Insert + if (!empty($eventFileID)) { + // Update physiological_task_event + $db->update( + 'physiological_task_event', + $instance, + ['PhysiologicalTaskEventID' => $instance_id] + ); + + $db->update( + 'physiological_event_file', + ['LastUpdate' => date("Y-m-d H:i:s")], + ['PhysiologicalFileID' => $this->_physioFileID] + ); + } + + $taskEvent = $db->pselect( + 'SELECT * FROM physiological_task_event + WHERE PhysiologicalTaskEventID=:PTEID', + ['PTEID' => $instance_id] + ); + + $extraColumns = $db->pselect( + 'SELECT opt.* + FROM physiological_task_event_opt AS opt + WHERE opt.PhysiologicalTaskEventID=:PTEID', + ['PTEID' => $instance_id] + ); + + return [ + 'instance' => $taskEvent[0], + 'extraColumns' => $extraColumns, + ]; + } + return []; + } + + /** + * Deletes one event instance + * + * @param int $physiologicalTaskEventID PhysiologicalTaskEventID + * + * @return void + */ + function deleteEvent(int $physiologicalTaskEventID): void + { + $db = \NDB_Factory::singleton()->database(); + + $physioFileID = $db->pselectone( + 'SELECT PhysiologicalFileID + FROM physiological_task_event + WHERE PhysiologicalTaskEventID=:taskEventID', + ['taskEventID' => $physiologicalTaskEventID] + ); + + // TODO: Check that this cascades properly to rel tables + if ($this->_physioFileID == $physioFileID) { + $db->delete( + "physiological_task_event", + ['PhysiologicalTaskEventID' => $physiologicalTaskEventID] + ); + } + } + + /** + * Updates the event files associated with the given + * physiological file ID + * + * @return void + * @throws SodiumException + */ + function updateFiles(): void + { + $db = \NDB_Factory::singleton()->database(); + + //Get data directory base path from Config + $config = \NDB_Factory::singleton()->config(); + $dataDir = $config->getSetting("dataDirBasepath"); + + $tsv = $db->pselect( + "SELECT + EventFileID AS id, + FilePath AS filePath, + ProjectID AS projectID, + LastUpdate AS lastUpdate, + LastWritten AS lastWritten + FROM physiological_event_file + WHERE PhysiologicalFileID=:PFID + AND FileType='tsv'", + ['PFID' => $this->_physioFileID] + ); + + if (count($tsv) > 0) { + $tsvPath = $dataDir . $tsv[0]['filePath']; + // Update files if files updated before database updated + if ($tsv[0]['lastWritten'] <= $tsv[0]['lastUpdate']) { + // events.tsv + $tsvFile = fopen($tsvPath, 'w'); // Will override all file content + + $extraColumns = $db->pselect( + "SELECT * + FROM physiological_task_event_opt + WHERE PhysiologicalTaskEventID IN ( + SELECT PhysiologicalTaskEventID + FROM physiological_task_event + WHERE PhysiologicalFileID=:PFID + )", + ['PFID' => $this->_physioFileID] + ); + + $columnNames = $db->pselect( + "SELECT DISTINCT PropertyName + FROM physiological_task_event_opt + WHERE PhysiologicalTaskEventID IN ( + SELECT PhysiologicalTaskEventID + FROM physiological_task_event + WHERE PhysiologicalFileID=:PFID + )", + ['PFID' => $this->_physioFileID] + ); + + // TODO: Make columns more dynamic + $tsvEntries = [ + 'onset', 'duration', 'sample', 'trial_type', + 'response_time', 'value' + ]; + foreach ($columnNames as $columnName) { + $tsvEntries[] = $columnName['PropertyName']; + } + // $tsvEntries[] = 'HED'; + + // Add columns names + $columns = implode("\t", $tsvEntries); + fwrite($tsvFile, "$columns\n"); + + $instances = $db->pselect( + "SELECT + PhysiologicalTaskEventID, + Onset, + Duration, + EventSample, + TrialType, + ResponseTime, + EventValue + FROM physiological_task_event + WHERE PhysiologicalFileID=:PFID", + ['PFID' => $this->_physioFileID] + ); + + foreach ($instances as $instance) { + // Setup each column in correct order + $inputTSV = [ + $instance['Onset'], + $instance['Duration'], + $instance['EventSample'], + $instance['TrialType'], + $instance['ResponseTime'], + $instance['EventValue'], + ]; + + $taskEventID = $instance['PhysiologicalTaskEventID']; + + // Get instance's extra columns + $instanceExtraColumns + = array_filter( + array_values($extraColumns), + function ($column) use ($taskEventID) { + return + $column['PhysiologicalTaskEventID'] == + $taskEventID; + } + ); + + foreach ($columnNames as $columnName) { + $column = array_filter( + array_values($instanceExtraColumns), + function ($col) use ($columnName) { + return + $col['PropertyName'] == + $columnName['PropertyName']; + } + ); + + $columnValue = count($column) > 0 + ? array_values($column)[0]['PropertyValue'] + : 'n/a'; + + $inputTSV[] = $columnValue; + } + + // Set all null values to 'n/a' + $inputTSV = array_map( + function ($v) { + return is_null($v) ? "n/a" : $v; + }, + $inputTSV + ); + + // Implode with tabs as delimiter + $input = implode("\t", $inputTSV); + + fwrite($tsvFile, $input . "\n"); + } + fclose($tsvFile); + + //Update archives and create new hash + $this->_updateArchives([$tsvPath]); + + // Update time that files were written to + $db->update( + 'physiological_event_file', + ['LastWritten' => date("Y-m-d H:i:s")], + ['PhysiologicalFileID' => $this->_physioFileID] + ); + } + } + } + + /** + * Convert column name from DB into BIDS-recognized column name + * + * @param string $columnName Column name from DB + * + * @return string + */ + function _getColumnName(string $columnName) : string + { + return match (strtolower($columnName)) { + 'eventvalue', 'event_value', 'value' => 'value', + 'trialtype' => 'trial_type', + default => $columnName, + }; + } + + /** + * Updates the event and physiological archives for the given + * physiological file ID with the provided paths and updates + * database with new archive file hash + * + * @param array $paths Paths to files to be added to archive + * + * @return void + * @throws SodiumException + */ + function _updateArchives(array $paths) : void + { + $db = \NDB_Factory::singleton()->database(); + + //Get data directory base path from Config + $config = \NDB_Factory::singleton()->config(); + $dataDir = $config->getSetting("dataDirBasepath"); + + $archive_table_names = [ + 'physiological_event_archive', + 'physiological_archive' + ]; + + foreach ($archive_table_names as $archive_table_name) { + $filepath = $db->pselectOne( + "SELECT + DISTINCT(FilePath) + FROM $archive_table_name + WHERE PhysiologicalFileID=:PFID", + ['PFID' => $this->_physioFileID] + ); + + $filepath = $dataDir . $filepath; + + $archive_file = new \PharData($filepath); + foreach ($paths as $path) { + $archive_file->addFile($path, basename($path)); + } + + $f = file_get_contents($filepath); + $hash = sodium_crypto_generichash($f); + + //Update database with hash + $db->update( + $archive_table_name, + ['Blake2bHash' => bin2hex($hash)], + ['PhysiologicalFileID' => $this->_physioFileID] + ); + } + } } diff --git a/modules/electrophysiology_browser/php/sessions.class.inc b/modules/electrophysiology_browser/php/sessions.class.inc index ee68788834f..f51b18172c1 100644 --- a/modules/electrophysiology_browser/php/sessions.class.inc +++ b/modules/electrophysiology_browser/php/sessions.class.inc @@ -17,7 +17,6 @@ namespace LORIS\electrophysiology_browser; use \Psr\Http\Message\ServerRequestInterface; use \Psr\Http\Message\ResponseInterface; use LORIS\electrophysiology_browser\Models\ElectrophysioFile; -use LORIS\electrophysiology_browser\Models\ElectrophysioAnnotations; use LORIS\electrophysiology_browser\Models\ElectrophysioEvents; /** @@ -50,7 +49,7 @@ class Sessions extends \NDB_Page { return (($user->hasPermission('electrophysiology_browser_view_allsites') || ($user->hasCenter($this->timepoint->getCenterID()) - && $user->hasPermission('electrophysiology_browser_view_site')) + && $user->hasPermission('electrophysiology_browser_view_site')) ) && $user->hasProject($this->timepoint->getProject()->getId())); } @@ -164,8 +163,8 @@ class Sessions extends \NDB_Page WHERE s.Active = "Y" AND pf.FileType IN ('. - '"bdf", "cnt", "edf", "set", "vhdr", "vsm", "archive"'. - ') ORDER BY pf.SessionID'; + '"bdf", "cnt", "edf", "set", "vhdr", "vsm", "archive"'. + ') ORDER BY pf.SessionID'; $response = []; @@ -292,12 +291,12 @@ class Sessions extends \NDB_Page $fileSummary['summary'], array_map( fn($channel) => - [ - 'name' => $channel.' Channel Count', - 'value' => $physioFileObj->getParameter( - $channel.'ChannelCount' - ), - ], + [ + 'name' => $channel.' Channel Count', + 'value' => $physioFileObj->getParameter( + $channel.'ChannelCount' + ), + ], $channels ) ); @@ -460,13 +459,6 @@ class Sessions extends \NDB_Page $fileSummary['downloads'] = $this->getDownloadLinks($physioFileObj); $fileSummary['chunks_urls'] = $physioFileObj->getChunksURLs(); - $fileSummary['epochsURL'] = $db->pselectOne( - "SELECT FilePath - FROM physiological_event_file - WHERE PhysiologicalFileID=:physioFileID - AND FileType='tsv'", - ['physioFileID' => $physioFileID] - ); $fileOutput = $db->pselectone( 'SELECT pot.OutputTypeName @@ -477,25 +469,20 @@ class Sessions extends \NDB_Page ['PFID' => $physioFileID] ); - // Get the annotation data - $annotations = new ElectrophysioAnnotations( - intval($physioFileID) - ); - $fileSummary['annotations'] = $annotations->getData(); - - // Get the task events data + // Get the task's event data $events = new ElectrophysioEvents( intval($physioFileID) ); $fileSummary['events'] = $events->getData(); - $fileSummary['epochsURL'] = $db->pselectOne( + $fileSummary['epochsURL'] = $db->pselectOne( "SELECT FilePath FROM physiological_event_file WHERE PhysiologicalFileID=:physioFileID AND FileType='tsv'", ['physioFileID' => $physioFileID] ); + $fileSummary['output_type'] = $fileOutput; $fileSummary['splitData'] = $physioFileObj->getSplitData(0); @@ -583,28 +570,37 @@ class Sessions extends \NDB_Page // Metadata $queries = [ - 'physiological_channel' => 'physiological_channel_file', - 'physiological_event_archive' => 'physiological_event_files', - 'physiological_annotation_archive' => 'physiological_annotation_files', - 'physiological_archive' => 'all_files', + 'physiological_electrode' => 'physiological_electrode_file', + 'physiological_coord_system' => 'physiological_coord_system_file', + 'physiological_channel' => 'physiological_channel_file', + 'physiological_event_archive' => 'physiological_event_files', + 'physiological_archive' => 'all_files', ]; $labels = [ - 'physiological_electrode_file' => 'Electrodes', - 'physiological_channel_file' => 'Channels', - 'physiological_event_files' => 'Events', - 'physiological_annotation_files' => 'Annotations', - 'all_files' => 'All Files', + 'physiological_electrode_file' => 'Electrodes', + 'physiological_coord_system_file' => 'Coordinate System', + 'physiological_channel_file' => 'Channels', + 'physiological_event_files' => 'Events', + 'all_files' => 'All Files', ]; foreach ($queries as $query_key => $query_value) { + // TODO: Revisit logic if we plan to support multiple electrode spaces if ($query_key == 'physiological_electrode') { // electrode filepath linked to coordinate system - $query_statement = "SELECT DISTINCT e.FilePath - FROM physiological_coord_system_electrode_rel AS r, - physiological_electrode AS e - WHERE r.PhysiologicalElectrodeID = e.PhysiologicalElectrodeID - AND r.PhysiologicalFileID=:PFID"; + $query_statement = "SELECT DISTINCT (FilePath) + FROM physiological_electrode + JOIN physiological_coord_system_electrode_rel + USING (PhysiologicalElectrodeID) + WHERE PhysiologicalFileID=:PFID"; + } else if ($query_key == 'physiological_coord_system') { + // coordinate system json + $query_statement = "SELECT DISTINCT (FilePath) + FROM physiological_coord_system + JOIN physiological_coord_system_electrode_rel + USING (PhysiologicalCoordSystemID) + WHERE PhysiologicalFileID=:PFID"; } else { // others metadata $query_statement = "SELECT DISTINCT FilePath @@ -628,29 +624,6 @@ class Sessions extends \NDB_Page ]; } } - - // Electrodes - $file_name = 'physiological_electrode_file'; - // TODO: If we plan to support multiple electrode spaces - // the LIMIT logic should be revisited - $query_statement = "SELECT DISTINCT(FilePath) - FROM physiological_electrode - JOIN physiological_coord_system_electrode_rel - USING (PhysiologicalElectrodeID) - WHERE PhysiologicalFileID=:PFID - LIMIT 1"; - $query_statement = $db->pselect( - $query_statement, - ['PFID' => $physioFileID] - ); - - $downloadLinks[$file_name] = [ - 'file' => '', - 'label' => $labels[$file_name], - ]; - if (count($query_statement) > 0) { - $downloadLinks[$file_name]['file'] = $query_statement[0]['FilePath']; - } return $downloadLinks; } diff --git a/modules/electrophysiology_browser/php/split_data.class.inc b/modules/electrophysiology_browser/php/split_data.class.inc index 529fc7d5d66..751cbe1db09 100644 --- a/modules/electrophysiology_browser/php/split_data.class.inc +++ b/modules/electrophysiology_browser/php/split_data.class.inc @@ -5,7 +5,7 @@ use \Psr\Http\Message\ResponseInterface; use LORIS\electrophysiology_browser\Models\ElectrophysioFile; /** - * Contains the Annotations class used for electrophysiological browser + * Contains the Split_Data class used for electrophysiological browser * * PHP Version 7 * @@ -57,4 +57,4 @@ class Split_Data extends \NDB_Page )); } } -} \ No newline at end of file +} diff --git a/modules/electrophysiology_browser/test/TestPlan.md b/modules/electrophysiology_browser/test/TestPlan.md index 034f93803d5..fd42442f0c1 100644 --- a/modules/electrophysiology_browser/test/TestPlan.md +++ b/modules/electrophysiology_browser/test/TestPlan.md @@ -1,45 +1,49 @@ ## Electrophysiology Browser test plan - + ### A. Electrophysiology Browser front page 1. User can load Electrophysiology Browser module front page if and only if user has either permission: - * `electrophysiology_browser_view_site` : _"View all-sites Electrophysiology Browser pages"_ [Automated Testing] - * `electrophysiology_browser_view_allsites` : _"View own site Electrophysiology Browser pages"_ [Automated Testing] -2. User can see other sites Electrophysiology datasets if and only if user has permission `electrophysiology_browser_view_allsites`. User can see only own-site datasets if and only if user has permission `electrophysiology_browser_view_site`. + * `electrophysiology_browser_view_site` : _"View all-sites Electrophysiology Browser pages"_ [Automated Testing] + * `electrophysiology_browser_view_allsites` : _"View own site Electrophysiology Browser pages"_ [Automated Testing] +2. User can see other sites Electrophysiology datasets if and only if user has permission `electrophysiology_browser_view_allsites`. User can see only own-site datasets if and only if user has permission `electrophysiology_browser_view_site`. 3. Test that all Filters work. [Automated Testing] 4. Test Clear Filters button. [Automated Testing] 5. Test column table is sortable by headers. [Automated Testing] 6. Test that Links work and point to correct dataset (raw/derivative). [Manual Testing] -### B. Subpage: Sessions +### B. Subpage: Sessions 7. User can view a session from any site if the user has `electrophysiology_browser_view_allsites` permissions. User can see only own-site session if the user has permission `electrophysiology_browser_view_site`. [Automated Testing] 8. User can view only own-project sessions if they have either `electrophysiology_browser_view_site` or `electrophysiology_browser_view_allsites` permissions. [Automated Testing] 9. Sidebar: Navigation links work. [Automated Testing] 10. Data table display: information displayed looks decently laid out, not garbled. -11. Click each "Download" button (there should be 6). Check: Does the download button work? Does the file that is downloaded have greater than 0kb size? Is a different file downloaded by each button? - * Check that if a session does not have annotation files, the `Annotations` download button is not clickable - * Check that if the session has annotation files, the `Annotations` download button is clickable and downloads the proper files +11. Click each "Download" button (there should be 5). Check: Does the download button work? Does the file that is downloaded have greater than 0kb size? Is a different file downloaded by each button? + * Check that if a session does not have event files, the `Events` download button is not clickable + * Check that if the session has event files, the `Events` download button is clickable and downloads the proper files 12. Test Breadcrumb link back to Electrophysiology Browser. [Automated Testing] +13. Test that if changes have been made to the session's events, the downloaded event files are correctly updated to match [Manual Testing] -### C. Visualization - -13. Follow the [module README extra installation steps](../README.md#installation-requirements-to-use-the-visualization-features) -and make sure the `Signal Viewer panel` displays correctly on the screen. (Documentation: see [react-series-data-viewer README](../jsx/react-series-data-viewer/README.md#user-manual)) -14. Delete `modules/electrophysiology_browser/jsx/react-series-data-viewer/src/protocol-buffers/chunk_pb.js` and set `useEEGBrowserVisualizationComponents` to false to simulate an environment for which the extra installation steps -have not been run yet. -Make sure `make dev` runs without failing, and that except the Signal Viewer panel, all the other components in the page display well. -15. Temporarily deactivate an entry in `physiological_parameter_file` -for a ParameterTypeID IN (SELECT ParameterTypeID from parameter_type WHERE Name = 'electrophysiology_chunked_dataset_path') -and a chosen PhysiologicalFileID to simulate an environment for which the visualization components are not loaded. -Load the corresponding session page and make sure that except the `Signal Viewer panel`, the rest of the page displays well, either with or without the extra installation steps. -16. Test all the buttons on the interface to ensure they perform the action that the [react-series-data-viewer README](../jsx/react-series-data-viewer/README.md#Signal Viewer) states it will perform. -17. Hover over a signal to ensure it responds to being hovered. It should change to a color and its value should be displayed below the signal plot. -18. Ensure that 'Stacked View' and 'Isolate Mode' behave as stateed in the [react-series-data-viewer README](../jsx/react-series-data-viewer/README.md). -19. Ensure that the electrodes on the 'Electrode Map' 2D view are visible and their index can be hovered to reveal their channel name. -20. Ensure that the electrodes on the 'Electrode Map' 3D view are visible and the mesh can be manipulated/rotated with the mouse. +### C. Visualization +14. Follow the [module README extra installation steps](../README.md#installation-requirements-to-use-the-visualization-features) + and make sure the `Signal Viewer panel` displays correctly on the screen. (Documentation: see [react-series-data-viewer README](../jsx/react-series-data-viewer/README.md#user-manual)) +15. Delete `modules/electrophysiology_browser/jsx/react-series-data-viewer/src/protocol-buffers/chunk_pb.js` and set `useEEGBrowserVisualizationComponents` to false to simulate an environment for which the extra installation steps + have not been run yet. + Make sure `make dev` runs without failing, and that except the Signal Viewer panel, all the other components in the page display well. +16. Temporarily deactivate an entry in `physiological_parameter_file` + for a ParameterTypeID IN (SELECT ParameterTypeID from parameter_type WHERE Name = 'electrophysiology_chunked_dataset_path') + and a chosen PhysiologicalFileID to simulate an environment for which the visualization components are not loaded. + Load the corresponding session page and make sure that except the Signal Viewer panel, the rest of the page displays well, either with or without the extra installation steps. +17. Make sure the 'Show Event Panel' opens the 'Event Panel' and it can be closed both via its close button and the 'Hide Event Panel' button. +18. Make sure the text fields can not be modified (support planned in future) . +19. Make sure HED tags belonging to an individual event can be added and deleted from that individual event. The selectable tags should only be SCORE 'Artifact's. +20. Make sure the 'Dataset Tag Viewer' can be opened with the 'Open Dataset Tag Viewer' button and the selectable fields are properly populated. +21. Test all the buttons on the interface to ensure they perform the action that the [react-series-data-viewer README](../jsx/react-series-data-viewer/README.md#Signal Viewer) states it will perform. +22. Hover over a signal to ensure it responds to being hovered. It should change to a color and its value should be displayed below the signal plot. +23. Ensure that 'Stacked View' and 'Isolate Mode' behave as stateed in the [react-series-data-viewer README](../jsx/react-series-data-viewer/README.md). +24. Ensure that the electrodes on the 'Electrode Map' 2D view are visible and their index can be hovered to reveal their channel name. +25. Ensure that the electrodes on the 'Electrode Map' 3D view are visible and the mesh can be manipulated/rotated with the mouse. -_For extra credit: Verify LORIS Menu permissions_ +_For extra credit: Verify LORIS Menu permissions_ User can view the top-level LORIS Menu _Electrophysiology_ and Menu item : _Electrophysiology Browser_ if and only if user has either permission: - * `electrophysiology_browser_view_site` : _"View all-sites Electrophysiology Browser pages"_ - * `electrophysiology_browser_view_allsites` : _"View own site Electrophysiology Browser pages"_ +* `electrophysiology_browser_view_site` : _"View all-sites Electrophysiology Browser pages"_ +* `electrophysiology_browser_view_allsites` : _"View own site Electrophysiology Browser pages"_ diff --git a/modules/genomic_browser/jsx/tabs_content/cnv.js b/modules/genomic_browser/jsx/tabs_content/cnv.js index bb38e50e276..de5a12b62d9 100644 --- a/modules/genomic_browser/jsx/tabs_content/cnv.js +++ b/modules/genomic_browser/jsx/tabs_content/cnv.js @@ -137,10 +137,7 @@ class CNV extends Component { filter: { name: 'Sex', type: 'select', - options: { - Male: 'Male', - Female: 'Female', - }, + options: options.Sex, }, }, { diff --git a/modules/genomic_browser/jsx/tabs_content/methylation.js b/modules/genomic_browser/jsx/tabs_content/methylation.js index 4d166597022..a054f6c31a1 100644 --- a/modules/genomic_browser/jsx/tabs_content/methylation.js +++ b/modules/genomic_browser/jsx/tabs_content/methylation.js @@ -138,10 +138,7 @@ class Methylation extends Component { filter: { name: 'Sex', type: 'select', - options: { - Male: 'Male', - Female: 'Female', - }, + options: options.Sex, }, }, { diff --git a/modules/genomic_browser/jsx/tabs_content/profiles.js b/modules/genomic_browser/jsx/tabs_content/profiles.js index c96cc9ce156..7ae5296d654 100644 --- a/modules/genomic_browser/jsx/tabs_content/profiles.js +++ b/modules/genomic_browser/jsx/tabs_content/profiles.js @@ -168,10 +168,7 @@ class Profiles extends Component { filter: { name: 'Sex', type: 'select', - options: { - Male: 'Male', - Female: 'Female', - }, + options: options.Sex, }, }, { diff --git a/modules/genomic_browser/jsx/tabs_content/snp.js b/modules/genomic_browser/jsx/tabs_content/snp.js index 235272c19ac..54b28a61c19 100644 --- a/modules/genomic_browser/jsx/tabs_content/snp.js +++ b/modules/genomic_browser/jsx/tabs_content/snp.js @@ -138,10 +138,7 @@ class SNP extends Component { filter: { name: 'Sex', type: 'select', - options: { - Male: 'Male', - Female: 'Female', - }, + options: options.Sex, }, }, { diff --git a/modules/genomic_browser/php/views/cnv.class.inc b/modules/genomic_browser/php/views/cnv.class.inc index c10eaf57fd5..dd1d8acb9a3 100644 --- a/modules/genomic_browser/php/views/cnv.class.inc +++ b/modules/genomic_browser/php/views/cnv.class.inc @@ -43,6 +43,7 @@ class CNV 'Sites' => $sites, 'Cohorts' => $cohorts, 'Platform' => $platform_options, + 'Sex' => \Utility::getSexList(), ]; } diff --git a/modules/genomic_browser/php/views/methylation.class.inc b/modules/genomic_browser/php/views/methylation.class.inc index 33c1444c992..76cd4715e34 100644 --- a/modules/genomic_browser/php/views/methylation.class.inc +++ b/modules/genomic_browser/php/views/methylation.class.inc @@ -57,6 +57,7 @@ class Methylation 'genotyping_platform', 'Name' ), + 'Sex' => \Utility::getSexList(), ]; } diff --git a/modules/genomic_browser/php/views/profiles.class.inc b/modules/genomic_browser/php/views/profiles.class.inc index 041866eded2..07db30828a5 100644 --- a/modules/genomic_browser/php/views/profiles.class.inc +++ b/modules/genomic_browser/php/views/profiles.class.inc @@ -41,6 +41,7 @@ class Profiles $this->_formElement = [ 'Sites' => $sites, 'Cohorts' => $cohorts, + 'Sex' => \Utility::getSexList(), ]; } diff --git a/modules/genomic_browser/php/views/snp.class.inc b/modules/genomic_browser/php/views/snp.class.inc index 3934145bf51..e96071db23e 100644 --- a/modules/genomic_browser/php/views/snp.class.inc +++ b/modules/genomic_browser/php/views/snp.class.inc @@ -43,6 +43,7 @@ class SNP 'Sites' => $sites, 'Cohorts' => $cohorts, 'Platform' => $platform_options, + 'Sex' => \Utility::getSexList(), ]; } diff --git a/modules/imaging_browser/php/imagingdictionaryitem.class.inc b/modules/imaging_browser/php/imagingdictionaryitem.class.inc new file mode 100644 index 00000000000..858b5ad7335 --- /dev/null +++ b/modules/imaging_browser/php/imagingdictionaryitem.class.inc @@ -0,0 +1,51 @@ +modality = $modality; + } + + /** + * Return the imaging modality that this dictionary item describes + * + * @return string + */ + public function getModality() : string + { + return $this->modality; + } +} diff --git a/modules/imaging_browser/php/locationdictionaryitem.class.inc b/modules/imaging_browser/php/locationdictionaryitem.class.inc new file mode 100644 index 00000000000..199cd9a5f96 --- /dev/null +++ b/modules/imaging_browser/php/locationdictionaryitem.class.inc @@ -0,0 +1,15 @@ +loris); + } } diff --git a/modules/imaging_browser/php/qcdictionaryitem.class.inc b/modules/imaging_browser/php/qcdictionaryitem.class.inc new file mode 100644 index 00000000000..7aa26e0483b --- /dev/null +++ b/modules/imaging_browser/php/qcdictionaryitem.class.inc @@ -0,0 +1,15 @@ +baseURL = $factory->settings()->getBaseURL(); + } + + /** + * {@inheritDoc} + * + * @return \LORIS\Data\Dictionary\Category[] + */ + public function getDataDictionary() : iterable + { + $scope = new Scope(Scope::SESSION); + $images = new \LORIS\Data\Dictionary\Category( + "Images", + "Image Acquisitions", + ); + $items = [ + new DictionaryItem( + "ScanDone", + "Does the session have any imaging scan done?", + $scope, + new \LORIS\Data\Types\BooleanType(), + new Cardinality(Cardinality::SINGLE), + ), + ]; + + $scantypes = \Utility::getScanTypeList(); + foreach ($scantypes as $ScanType) { + $items[] = new LocationDictionaryItem( + $ScanType . "_file", + "$ScanType acquisition file path", + $scope, + new \LORIS\Data\Types\StringType(), + new Cardinality(Cardinality::MANY), + $ScanType, + ); + $items[] = new LocationDictionaryItem( + $ScanType . "_url", + "$ScanType acquisition file URL", + $scope, + new \LORIS\Data\Types\URI(), + new Cardinality(Cardinality::MANY), + $ScanType, + ); + // TODO: Investigate adding a file scope instead of having this apply + // on a session scope with a Many cardinality. + $items[] = new QCDictionaryItem( + $ScanType . "_QCStatus", + "Quality control status for $ScanType acquisition", + $scope, + new \LORIS\Data\Types\Enumeration("Pass", "Fail"), + new Cardinality(Cardinality::MANY), + $ScanType, + ); + } + $images = $images->withItems($items); + + return [$images]; + } + + /** + * {@inheritDoc} + * + * @param \LORIS\Data\Dictionary\Category $inst The item category + * @param \LORIS\Data\Dictionary\DictionaryItem $item The item itself + * + * @return string[] + */ + public function getVisitList( + \LORIS\Data\Dictionary\Category $inst, + \LORIS\Data\Dictionary\DictionaryItem $item + ) : iterable { + if ($item->getScope()->__toString() !== 'session') { + return []; + } + + if ($item instanceof ImagingDictionaryItem) { + $DB = \NDB_Factory::singleton()->database(); + $visits = $DB->pselectCol( + "SELECT DISTINCT s.Visit_label + FROM files f + JOIN session s ON (f.SessionID=s.ID) + JOIN candidate c ON (c.CandID=s.CandID) + JOIN mri_scan_type mst ON (mst.ID=f.AcquisitionProtocolID) + WHERE + c.Active='Y' AND + s.Active='Y' AND + mst.Scan_type=:scantype AND + c.Entity_Type='Human' + ORDER BY s.Visit_label", + ['scantype' => $item->getModality()], + ); + return $visits; + } + + // Fall back on all visits if something ends up getting + // added that we can't derive the modality of. + return array_keys(\Utility::getVisitList()); + } + + /** + * {@inheritDoc} + * + * @param \LORIS\Data\Dictionary\DictionaryItem $item - the field + * + * @return string + */ + protected function getFieldNameFromDict( + \LORIS\Data\Dictionary\DictionaryItem $item + ): string { + if ($item->getName() == 'ScanDone') { + $this->addTable('LEFT JOIN session s ON (s.CandID=c.CandID)'); + return "CASE WHEN s.Scan_Done='Y' THEN true + WHEN s.Scan_Done='N' THEN false + ELSE NULL END"; + } + if ($item instanceof LocationDictionaryItem) { + $modality = $item->getModality(); + + $this->addTable('LEFT JOIN session s ON (s.CandID=c.CandID)'); + $this->addTable("LEFT JOIN files ON (s.ID=files.SessionID)"); + + // Because of complex interactions between joins that get + // out of hand when a scan type that doesn't exist is selected + // alongside one that does, we use a subselect here. + if (str_ends_with($item->getName(), "_file")) { + return "(SELECT File FROM files as files2 + JOIN mri_scan_type + ON (files2.AcquisitionProtocolID=mri_scan_type.ID) + WHERE files2.FileID=files.FileID + AND mri_scan_type.Scan_type='{$modality}')"; + } else if (str_ends_with($item->getName(), "_url")) { + return "(SELECT CONCAT( + \"$this->baseURL\", + \"/api/v0.0.3/candidates/\", + c.CandID, + \"/\", + s.Visit_label, + \"/images/\", + SUBSTRING_INDEX(files.File, '/', -1) + ) FROM files as files2 + JOIN mri_scan_type + ON (files2.AcquisitionProtocolID=mri_scan_type.ID) + WHERE files2.FileID=files.FileID + AND mri_scan_type.Scan_type='{$modality}')"; + } + } + if ($item instanceof QCDictionaryItem) { + $modality = $item->getModality(); + + $this->addTable('LEFT JOIN session s ON (s.CandID=c.CandID)'); + $this->addTable("LEFT JOIN files ON (s.ID=files.SessionID)"); + return "(SELECT QCStatus FROM files_qcstatus + JOIN files as files2 + JOIN mri_scan_type + ON (files2.AcquisitionProtocolID=mri_scan_type.ID) + WHERE files_qcstatus.FileID=files.FileID + AND files2.FileID=files.FileID + AND mri_scan_type.Scan_type='{$modality}')"; + } + + throw new \DomainException("Invalid field " . $item->getName()); + } + + /** + * {@inheritDoc} + * + * @param \LORIS\Data\Dictionary\DictionaryItem $item - The field + * + * @return string + */ + public function getCorrespondingKeyFieldType( + \LORIS\Data\Dictionary\DictionaryItem $item + ) : string { + return "Imaging Filename"; + } + + /** + * {@inheritDoc} + * + * @param \LORIS\Data\Dictionary\DictionaryItem $item - The field + * + * @return string + */ + public function getCorrespondingKeyField( + \LORIS\Data\Dictionary\DictionaryItem $item + ) { + if ($item instanceof LocationDictionaryItem + || $item instanceof QCDictionaryItem + ) { + $this->addTable('LEFT JOIN session s ON (s.CandID=c.CandID)'); + $this->addTable("LEFT JOIN files ON (s.ID=files.SessionID)"); + return "SUBSTRING_INDEX(files.File, '/', -1)"; + } + throw new \Exception( + "Unhandled Cardinality::MANY field " . $item->getName() + ); + } +} diff --git a/modules/imaging_browser/test/ImagingQueryEngineTest.php b/modules/imaging_browser/test/ImagingQueryEngineTest.php new file mode 100644 index 00000000000..4c79b569798 --- /dev/null +++ b/modules/imaging_browser/test/ImagingQueryEngineTest.php @@ -0,0 +1,472 @@ +factory = NDB_Factory::singleton(); + $this->factory->reset(); + + $this->config = $this->factory->Config("../project/config.xml"); + + $database = $this->config->getSetting('database'); + + $this->DB = $this->factory->database( + $database['database'], + $database['username'], + $database['password'], + $database['host'], + true, + ); + + $this->factory->setDatabase($this->DB); + + $this->DB->setFakeTableData( + "candidate", + [ + [ + 'ID' => 1, + 'CandID' => "123456", + 'PSCID' => "test1", + 'RegistrationProjectID' => '1', + 'RegistrationCenterID' => '1', + 'Active' => 'Y', + 'DoB' => '1920-01-30', + 'DoD' => '1950-11-16', + 'Sex' => 'Male', + 'EDC' => null, + 'Entity_type' => 'Human', + ], + [ + 'ID' => 2, + 'CandID' => "123457", + 'PSCID' => "test2", + 'RegistrationProjectID' => '1', + 'RegistrationCenterID' => '2', + 'Active' => 'Y', + 'DoB' => '1930-05-03', + 'DoD' => null, + 'Sex' => 'Female', + 'EDC' => '1930-04-01', + 'Entity_type' => 'Human', + ], + [ + 'ID' => 3, + 'CandID' => "123458", + 'PSCID' => "test3", + 'RegistrationProjectID' => '1', + 'RegistrationCenterID' => '3', + 'Active' => 'N', + 'DoB' => '1940-01-01', + 'Sex' => 'Other', + 'EDC' => '1930-04-01', + 'Entity_type' => 'Human', + ], + ] + ); + + $this->DB->setFakeTableData( + "session", + [ + // Candidate 123456 has 2 visits, one with MRI data and one + // without. + [ + 'ID' => 1, + 'CandID' => "123456", + 'CenterID' => 1, + 'ProjectID' => 1, + 'CohortID' => 1, + 'Active' => 'Y', + 'Visit_Label' => 'TestMRIVisit', + 'Scan_Done' => 'Y' + ], + [ + 'ID' => 2, + 'CandID' => "123456", + 'CenterID' => 1, + 'ProjectID' => 1, + 'CohortID' => 1, + 'Active' => 'Y', + 'Visit_Label' => 'TestBvlVisit', + 'Scan_Done' => 'N' + ], + // Candidate 123457 has 1 visit with different MRI data + // It contains multiple ScanType1 and no ScanType2 + [ + 'ID' => 3, + 'CandID' => "123457", + 'CenterID' => 1, + 'ProjectID' => 1, + 'CohortID' => 1, + 'Active' => 'Y', + 'Visit_Label' => 'TestMRIVisit', + 'Scan_Done' => 'Y' + ], + ] + ); + + $this->DB->setFakeTableData( + "mri_scan_type", + [ + [ + 'ID' => 98, + 'Scan_type' => 'ScanType1', + ], + [ + 'ID' => 99, + 'Scan_type' => 'ScanType2', + ], + ] + ); + + $this->DB->setFakeTableData( + "files", + [ + [ + 'FileID' => 1, + 'SessionID' => 1, + 'AcquisitionProtocolID' => 98, + 'File' => 'test/abc.file' + ], + [ + 'FileID' => 2, + 'SessionID' => 3, + 'AcquisitionProtocolID' => 98, + 'File' => 'test/abc.file1' + ], + [ + 'FileID' => 3, + 'SessionID' => 3, + 'AcquisitionProtocolID' => 98, + 'File' => 'test/abc.file2' + ], + [ + 'FileID' => 4, + 'SessionID' => 3, + 'AcquisitionProtocolID' => 99, + 'File' => 'test/Scantype2' + ], + ] + ); + + $this->DB->setFakeTableData( + "files_qcstatus", + [ + [ + 'FileID' => 1, + 'QCStatus' => 'Pass' + ], + [ + 'FileID' => 2, + 'QCStatus' => 'Fail' + ], + [ + 'FileID' => 3, + 'QCStatus' => 'Pass' + ], + ] + ); + // Ensure tests are run using this module directory with no overrides. + // We are in test, so .. brings us to candidate_parameters and ../../ brings + // us to modules for the LorisInstance config. + $lorisinstance = new \LORIS\LorisInstance( + $this->DB, + $this->config, + [__DIR__ . "/../../"] + ); + + $this->engine = $lorisinstance->getModule( + 'imaging_browser' + )->getQueryEngine(); + } + + /** + * {@inheritDoc} + * + * @return void + */ + function tearDown() : void + { + $this->DB->run("DROP TEMPORARY TABLE IF EXISTS files_qcstatus"); + $this->DB->run("DROP TEMPORARY TABLE IF EXISTS session"); + $this->DB->run("DROP TEMPORARY TABLE IF EXISTS files"); + $this->DB->run("DROP TEMPORARY TABLE IF EXISTS mri_scan_type"); + $this->DB->run("DROP TEMPORARY TABLE IF EXISTS candidate"); + } + + /** + * Test that matching ScanDone matches the correct CandIDs. + * + * @return void + */ + public function testScanDoneMatches() + { + $dict = $this->_getDictItem("ScanDone"); + + $result = $this->engine->getCandidateMatches( + new QueryTerm($dict, new Equal(true)) + ); + + // 123456 has a ScanDone = true result for visit TestMRIVisit + // So does 123457. + $this->assertTrue(is_array($result)); + $this->assertEquals(2, count($result)); + $this->assertEquals($result[0], new CandID("123456")); + $this->assertEquals($result[1], new CandID("123457")); + + // 123456 has a ScanDone = false result for visit TestBvlVisit + // No other candidate has a ScanDone=false session. + $result = $this->engine->getCandidateMatches( + new QueryTerm($dict, new NotEqual(true)) + ); + $this->assertTrue(is_array($result)); + $this->assertEquals(1, count($result)); + $this->assertEquals($result[0], new CandID("123456")); + } + + /** + * Test that matching a modality matches the correct CandIDs. + * + * @return void + */ + public function testImageLocationMatches() + { + $dict = $this->_getDictItem("ScanType1_file"); + + $result = $this->engine->getCandidateMatches( + new QueryTerm($dict, new Equal('test/abc.file')) + ); + + // 123456 has ScanType1 at session 1 + $this->assertTrue(is_array($result)); + $this->assertEquals(1, count($result)); + $this->assertEquals($result[0], new CandID("123456")); + + // 123456 has no files that aren't equal to test/abc.file + // 123457 has files that are not equal to test/abc.file. + $result = $this->engine->getCandidateMatches( + new QueryTerm($dict, new NotEqual('test/abc.file')) + ); + $this->assertTrue(is_array($result)); + $this->assertEquals(1, count($result)); + $this->assertEquals($result[0], new CandID("123457")); + + // Both 123456 and 123457 have files that start with test/abc + $result = $this->engine->getCandidateMatches( + new QueryTerm($dict, new StartsWith('test/abc')) + ); + $this->assertTrue(is_array($result)); + $this->assertEquals(2, count($result)); + $this->assertEquals($result[0], new CandID("123456")); + $this->assertEquals($result[1], new CandID("123457")); + + // Both 123456 and 123457 have files that contain abc + $result = $this->engine->getCandidateMatches( + new QueryTerm($dict, new Substring('abc')) + ); + $this->assertTrue(is_array($result)); + $this->assertEquals(2, count($result)); + $this->assertEquals($result[0], new CandID("123456")); + $this->assertEquals($result[1], new CandID("123457")); + + // Only 123457 has files that end with abc.file1 + $result = $this->engine->getCandidateMatches( + new QueryTerm($dict, new EndsWith('abc.file1')) + ); + $this->assertTrue(is_array($result)); + $this->assertEquals(1, count($result)); + $this->assertEquals($result[0], new CandID("123457")); + } + + /** + * Test that matching a QC Status matches the correct CandIDs. + * + * @return void + */ + public function testImageQCMatches() + { + $dict = $this->_getDictItem("ScanType1_QCStatus"); + + // Both candidates have a passed scan + $result = $this->engine->getCandidateMatches( + new QueryTerm($dict, new Equal('Pass')) + ); + $this->assertEquals(2, count($result)); + $this->assertEquals($result[0], new CandID("123456")); + $this->assertEquals($result[1], new CandID("123457")); + + // Only 123457 has a failed scan. + $result = $this->engine->getCandidateMatches( + new QueryTerm($dict, new Equal('Fail')) + ); + $this->assertEquals(1, count($result)); + $this->assertEquals($result[0], new CandID("123457")); + + // The failed scan is the only not Equal to pass Scan + $result = $this->engine->getCandidateMatches( + new QueryTerm($dict, new NotEqual('Pass')) + ); + $this->assertEquals(1, count($result)); + $this->assertEquals($result[0], new CandID("123457")); + + // The failed scan is still the only failed scan with an "IN" criteria + // The failed scan is the only not Equal to pass Scan + $result = $this->engine->getCandidateMatches( + new QueryTerm($dict, new In('Fail')) + ); + $this->assertEquals(1, count($result)); + $this->assertEquals($result[0], new CandID("123457")); + + // FIXME: Exists is an option on the frontend, should test. + + } + + /** + * Ensures that getCandidateData works for all field types + * in the dictionary. + * + * @return void + */ + function testGetCandidateData() + { + // Test getting some candidate scoped data + $results = iterator_to_array( + $this->engine->getCandidateData( + [ + $this->_getDictItem("ScanDone"), + $this->_getDictItem("ScanType1_file"), + $this->_getDictItem("ScanType1_QCStatus"), + ], + [new CandID("123456"), new CandID("123457"), new CandID("123458")], + null + ) + ); + + // 123458 had no files, but has a session, so still has the ScanDone + $this->assertEquals(count($results), 3); + $this->assertEquals( + $results, + [ + "123456" => [ + "ScanDone" => [ + "1" => [ + 'VisitLabel' => 'TestMRIVisit', + 'SessionID' => 1, + 'value' => true, + ], + "2" => [ + 'VisitLabel' => 'TestBvlVisit', + 'SessionID' => 2, + 'value' => false, + ], + ], + "ScanType1_file" => [ + 'keytype' => 'Imaging Filename', + "1" => [ + 'VisitLabel' => 'TestMRIVisit', + 'SessionID' => 1, + 'values' => ['abc.file' => 'test/abc.file'], + ], + ], + "ScanType1_QCStatus" => [ + 'keytype' => 'Imaging Filename', + "1" => [ + 'VisitLabel' => 'TestMRIVisit', + 'SessionID' => 1, + 'values' => ['abc.file' => 'Pass'], + ], + ], + ], + "123457" => [ + "ScanDone" => [ + "3" => [ + 'VisitLabel' => 'TestMRIVisit', + 'SessionID' => 3, + 'value' => true, + ] + ], + "ScanType1_file" => [ + 'keytype' => 'Imaging Filename', + "3" => [ + 'VisitLabel' => 'TestMRIVisit', + 'SessionID' => 3, + 'values' => [ + 'abc.file1' => 'test/abc.file1', + 'abc.file2' => 'test/abc.file2' + ], + ] + ], + "ScanType1_QCStatus" => [ + 'keytype' => 'Imaging Filename', + "3" => [ + 'VisitLabel' => 'TestMRIVisit', + 'SessionID' => 3, + 'values' => [ + 'abc.file1' => 'Fail', + 'abc.file2' => 'Pass' + ], + ], + ], + ], + "123458" => [ + 'ScanDone' => [], + 'ScanType1_file' => [], + 'ScanType1_QCStatus' => [], + ], + ] + ); + } + + /** + * Gets a dictionary item named $name, in any + * category. + * + * @param string $name The dictionary item name + * + * @return \LORIS\Data\Dictionary\DictionaryItem + */ + private function _getDictItem(string $name) + { + $categories = $this->engine->getDataDictionary(); + foreach ($categories as $category) { + $items = $category->getItems(); + foreach ($items as $item) { + if ($item->getName() == $name) { + return $item; + } + } + } + throw new \Exception("Could not get dictionary item"); + } +} + diff --git a/modules/imaging_qc/jsx/imagingQCIndex.js b/modules/imaging_qc/jsx/imagingQCIndex.js index 3c198d4a717..c04c506c0c4 100644 --- a/modules/imaging_qc/jsx/imagingQCIndex.js +++ b/modules/imaging_qc/jsx/imagingQCIndex.js @@ -93,7 +93,6 @@ class ImagingQCIndex extends Component { }) .catch((error) => { this.setState({error: error}); - console.log(error); }); } diff --git a/modules/imaging_uploader/jsx/ImagingUploader.js b/modules/imaging_uploader/jsx/ImagingUploader.js index 11f103d0124..00264b46ace 100644 --- a/modules/imaging_uploader/jsx/ImagingUploader.js +++ b/modules/imaging_uploader/jsx/ImagingUploader.js @@ -7,6 +7,7 @@ import Loader from 'Loader'; import LogPanel from './LogPanel'; import UploadForm from './UploadForm'; import {TextboxElement, SelectElement, ButtonElement} from 'jsx/Form'; +import StaticDataTable from 'jsx/StaticDataTable'; /** * Imaging uploader component diff --git a/modules/instrument_builder/jsx/react.questions.js b/modules/instrument_builder/jsx/react.questions.js index 603b947d4b4..39520ed1e74 100644 --- a/modules/instrument_builder/jsx/react.questions.js +++ b/modules/instrument_builder/jsx/react.questions.js @@ -11,6 +11,14 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; +import { + SelectElement, + DateElement, + TextareaElement, + TextElement, + NumericElement, + ButtonElement, +} from 'jsx/Form'; /** * Note: This is a wrapper for Form.js (Only used in instrument builder) diff --git a/modules/instrument_list/templates/instrument_list_controlpanel.tpl b/modules/instrument_list/templates/instrument_list_controlpanel.tpl index 9f459ce74d2..aa76f55fd48 100644 --- a/modules/instrument_list/templates/instrument_list_controlpanel.tpl +++ b/modules/instrument_list/templates/instrument_list_controlpanel.tpl @@ -16,13 +16,21 @@ {section name=item loop=$status}
  • {if $access.status and $status[item].showlink|default} - {$status[item].label} + {assign var="onclickValue" value="{$status[item].label}"} + + + + + {$status[item].label} + {else} - {$status[item].label} + {$status[item].label} {/if}
  • {/section} -

    Send Time Point

    @@ -90,3 +98,18 @@ {/if} + + \ No newline at end of file diff --git a/modules/instrument_list/templates/menu_instrument_list.tpl b/modules/instrument_list/templates/menu_instrument_list.tpl index a9a4bdf8bdf..1a18ad642af 100644 --- a/modules/instrument_list/templates/menu_instrument_list.tpl +++ b/modules/instrument_list/templates/menu_instrument_list.tpl @@ -15,10 +15,21 @@ Biological Sex - {if $display.ProjectTitle != ""} + {if $display.ProjectTitle == $display.ProjectName && $display.ProjectName != ""} Project + {else} + {if $display.ProjectTitle != ""} + + Candidate Registration Project + + {/if} + {if $display.ProjectName != ""} + + Timepoint Project + + {/if} {/if} {foreach from=$display.DisplayParameters item=value key=name} @@ -68,10 +79,21 @@ {$display.Sex} - {if $display.ProjectTitle != ""} + {if $display.ProjectName != "" && $display.ProjectName == $display.ProjectTitle} - {$display.ProjectTitle} + {$display.ProjectName} + {else} + {if $display.ProjectTitle != ""} + + {$display.ProjectTitle} + + {/if} + {if $display.ProjectName != ""} + + {$display.ProjectName} + + {/if} {/if} {foreach from=$display.DisplayParameters item=value key=name} diff --git a/modules/instrument_manager/jsx/uploadForm.js b/modules/instrument_manager/jsx/uploadForm.js index e83d11fa4c0..a0ba79ae257 100644 --- a/modules/instrument_manager/jsx/uploadForm.js +++ b/modules/instrument_manager/jsx/uploadForm.js @@ -1,6 +1,7 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import swal from 'sweetalert2'; +import {FileElement} from 'jsx/Form'; /** * Instrument Upload Form component diff --git a/modules/issue_tracker/jsx/IssueForm.js b/modules/issue_tracker/jsx/IssueForm.js index b32eea0cc52..2f6030480b4 100644 --- a/modules/issue_tracker/jsx/IssueForm.js +++ b/modules/issue_tracker/jsx/IssueForm.js @@ -14,6 +14,7 @@ import { TextboxElement, ButtonElement, TextareaElement, + FileElement, } from 'jsx/Form'; /** diff --git a/modules/login/jsx/passwordExpiry.js b/modules/login/jsx/passwordExpiry.js index b928ea316bb..4c553bfd823 100644 --- a/modules/login/jsx/passwordExpiry.js +++ b/modules/login/jsx/passwordExpiry.js @@ -1,6 +1,12 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import Panel from 'Panel'; +import { + FormElement, + PasswordElement, + ButtonElement, +} from 'jsx/Form'; + /** * Password expired form. diff --git a/modules/media/ajax/FileDownload.php b/modules/media/ajax/FileDownload.php deleted file mode 100644 index d2ea74d82c6..00000000000 --- a/modules/media/ajax/FileDownload.php +++ /dev/null @@ -1,50 +0,0 @@ - - * @license Loris license - * @link https://github.com/aces/Loris-Trunk - */ - -$user =& User::singleton(); -//NOTE Should this be 'media_read' instead? It seems that downloading files -//should be a read permission, not write. -if (!$user->hasPermission('media_read')) { - header("HTTP/1.1 403 Forbidden"); - exit; -} - -// Make sure that the user isn't trying to break out of the $path -// by using a relative filename. -$file = html_entity_decode(basename($_GET['File'])); -$config =& NDB_Config::singleton(); -$path = $config->getSetting('mediaPath'); -$filePath = $path . $file; - -$downloadNotifier = new NDB_Notifier( - "media", - "download", - ["file" => $file] -); - -if (!file_exists($filePath)) { - error_log("ERROR: File $filePath does not exist"); - header("HTTP/1.1 404 Not Found"); - exit(5); -} - -// Output file in downloadable format -header('Content-Description: File Transfer'); -header('Content-Type: application/force-download'); -header("Content-Transfer-Encoding: Binary"); -header("Content-disposition: attachment; filename=\"" . basename($filePath) . "\""); -readfile($filePath); -$downloadNotifier->notify(); diff --git a/modules/media/jsx/editForm.js b/modules/media/jsx/editForm.js index 579bc9d15db..eed8053a294 100644 --- a/modules/media/jsx/editForm.js +++ b/modules/media/jsx/editForm.js @@ -12,7 +12,15 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import swal from 'sweetalert2'; - +import { + FormElement, + TextboxElement, + TextareaElement, + SelectElement, + DateElement, + FileElement, + ButtonElement, +} from 'jsx/Form'; /** * Media Edit Form component */ diff --git a/modules/media/jsx/mediaIndex.js b/modules/media/jsx/mediaIndex.js index 01071fc76eb..4def4dfe2b2 100644 --- a/modules/media/jsx/mediaIndex.js +++ b/modules/media/jsx/mediaIndex.js @@ -109,7 +109,7 @@ class MediaIndex extends Component { case 'File Name': if (this.props.hasPermission('media_write')) { const downloadURL = loris.BaseURL - + '/media/ajax/FileDownload.php?File=' + + '/media/files/' + encodeURIComponent(row['File Name']); result = ( diff --git a/modules/media/php/files.class.inc b/modules/media/php/files.class.inc new file mode 100644 index 00000000000..541cd3b762c --- /dev/null +++ b/modules/media/php/files.class.inc @@ -0,0 +1,49 @@ +hasPermission('media_write'); + } + + /** + * {@inheritDoc} + * + * @param \NDB_Config $config the LORIS configuration object to retrieve + * settings from. + * + * @return \SplFileInfo + */ + protected function getDownloadDirectory(\NDB_Config $config): \SplFileInfo + { + return new \SplFileInfo($config->getSetting("mediaPath")); + } + + /** + * {@inheritDoc} + * + * @return string + */ + protected function getEndpointPrefix(): string + { + return "/files/"; + } +} diff --git a/modules/new_profile/php/new_profile.class.inc b/modules/new_profile/php/new_profile.class.inc index 71b7775d1e1..86391af9032 100644 --- a/modules/new_profile/php/new_profile.class.inc +++ b/modules/new_profile/php/new_profile.class.inc @@ -46,14 +46,11 @@ class New_Profile extends \NDB_Form $ageMin = $config->getSetting('ageMin'); $dobFormat = $config->getSetting('dobFormat'); $edc = $config->getSetting('useEDC'); - $sex = [ - 'Male' => 'Male', - 'Female' => 'Female', - 'Other' => 'Other', - ]; - $pscidSet = "false"; - $minYear = (isset($startYear, $ageMax)) ? $startYear - $ageMax : null; - $maxYear = (isset($endYear, $ageMin)) ? $endYear - $ageMin : null; + $sex = \Utility::getSexList(); + + $pscidSet = "false"; + $minYear = (isset($startYear, $ageMax)) ? $startYear - $ageMax : null; + $maxYear = (isset($endYear, $ageMin)) ? $endYear - $ageMin : null; // Get sites for the select dropdown $user_list_of_sites = $user->getCenterIDs(); diff --git a/modules/publication/ajax/FileDownload.php b/modules/publication/ajax/FileDownload.php deleted file mode 100644 index 047fcb13fe7..00000000000 --- a/modules/publication/ajax/FileDownload.php +++ /dev/null @@ -1,62 +0,0 @@ - - * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 - * @link https://github.com/aces/Loris-Trunk - */ - -$factory = \NDB_Factory::singleton(); -$user = $factory->user(); -$message = ['message' => null]; - -if (userCanDownload($user)) { - // Make sure that the user isn't trying to break out of the $path - // by using a relative filename. - $file = basename($_GET['File']); - $config = NDB_Config::singleton(); - $path = $config->getSetting('publication_uploads'); - $filePath = $path . $file; - - if (!file_exists($filePath)) { - error_log("ERROR: File $filePath does not exist"); - http_response_code(404); - $message['message'] = "Could not locate file: $file"; - exit(json_encode($message)); - } - - // Output file in downloadable format - header('Content-Description: File Transfer'); - header("Content-Transfer-Encoding: Binary"); - header("Content-disposition: attachment; filename=\"" . $file . "\""); - readfile($filePath); -} else { - http_response_code(403); - $message['message'] = 'You do not have permission to download this file.'; - exit(json_encode($message)); -} - -/** - * Permission check - * - * @param User $user user - * - * @return bool - */ -function userCanDownload($user) : bool -{ - $retVal = false; - if ($user->hasPermission('publication_view') - || $user->hasPermission('publication_propose') - || $user->hasPermission('publication_approve') - ) { - $retVal = true; - } - - return $retVal; -} \ No newline at end of file diff --git a/modules/publication/ajax/getData.php b/modules/publication/ajax/getData.php index 51abd4b5758..3fd0dfd8752 100644 --- a/modules/publication/ajax/getData.php +++ b/modules/publication/ajax/getData.php @@ -188,6 +188,7 @@ function getProjectData($db, $user, $id) : array $datePublication = htmlspecialchars_decode($result['datePublication'] ?? ''); $journal = htmlspecialchars_decode($result['journal'] ?? ''); $link = htmlspecialchars_decode($result['link'] ?? ''); + $publishingStatus = htmlspecialchars_decode( $result['publishingStatus'] ?? '' diff --git a/modules/publication/jsx/projectFields.js b/modules/publication/jsx/projectFields.js index 2b24c5591bc..f7ed8569418 100644 --- a/modules/publication/jsx/projectFields.js +++ b/modules/publication/jsx/projectFields.js @@ -196,7 +196,7 @@ class ProjectFormFields extends React.Component { if (this.props.files) { this.props.files.forEach(function(f) { let downloadURL = loris.BaseURL - + '/publication/ajax/FileDownload.php?File=' + + '/publication/files/' + encodeURIComponent(f.Filename); let link = ( diff --git a/modules/publication/jsx/publicationIndex.js b/modules/publication/jsx/publicationIndex.js index f81c66720ef..e99564a460b 100644 --- a/modules/publication/jsx/publicationIndex.js +++ b/modules/publication/jsx/publicationIndex.js @@ -5,6 +5,7 @@ import {createRoot} from 'react-dom/client'; import React from 'react'; import PropTypes from 'prop-types'; import {ButtonElement} from 'jsx/Form'; +import StaticDataTable from 'jsx/StaticDataTable'; /** * Publication index component diff --git a/modules/publication/jsx/viewProject.js b/modules/publication/jsx/viewProject.js index 8318fc3a29c..b479ba224f9 100644 --- a/modules/publication/jsx/viewProject.js +++ b/modules/publication/jsx/viewProject.js @@ -183,7 +183,7 @@ class ViewProject extends React.Component { let toReturn = []; files.forEach(function(f) { let download = loris.BaseURL - + '/publication/ajax/FileDownload.php?File=' + + '/publication/files/' + f.Filename; let link = {f.Filename}; let uploadType = this.state.uploadTypes[f.PublicationUploadTypeID]; diff --git a/modules/publication/php/files.class.inc b/modules/publication/php/files.class.inc new file mode 100644 index 00000000000..2704d19b4d3 --- /dev/null +++ b/modules/publication/php/files.class.inc @@ -0,0 +1,52 @@ +hasAnyPermission( + [ + 'publication_view', + 'publication_propose', + 'publication_approve', + ] + ); + } + + /** + * {@inheritDoc} + * + * @param \NDB_Config $config the LORIS configuration object to retrieve + * settings from. + * + * @return \SplFileInfo + */ + protected function getDownloadDirectory(\NDB_Config $config): \SplFileInfo + { + return new \SplFileInfo($config->getSetting("publication_uploads")); + } + + /** + * {@inheritDoc} + * + * @return string + */ + protected function getEndpointPrefix(): string + { + return "/files/"; + } +} diff --git a/modules/publication/php/publication.class.inc b/modules/publication/php/publication.class.inc index 33b3b505710..75a6377816d 100644 --- a/modules/publication/php/publication.class.inc +++ b/modules/publication/php/publication.class.inc @@ -301,7 +301,10 @@ class Publication extends \NDB_Menu_Filter_Form array_walk_recursive( $result, function (&$r) { - $r = htmlspecialchars_decode($r ?? ''); + if ($r === null) { + return $r; + } + $r = htmlspecialchars_decode($r); } ); $result['form'] = $this->form->form; diff --git a/modules/user_accounts/jsx/userAccountsIndex.js b/modules/user_accounts/jsx/userAccountsIndex.js index 195cce12bc1..440d9139477 100644 --- a/modules/user_accounts/jsx/userAccountsIndex.js +++ b/modules/user_accounts/jsx/userAccountsIndex.js @@ -151,7 +151,7 @@ class UserAccountsIndex extends Component { const fields = [ {label: 'Site', show: true, filter: { name: 'site', - type: 'select', + type: 'multiselect', options: options.sites, }}, {label: 'Project', show: true, filter: { diff --git a/php/libraries/Database.class.inc b/php/libraries/Database.class.inc index a2ce4f60719..29eba54a958 100644 --- a/php/libraries/Database.class.inc +++ b/php/libraries/Database.class.inc @@ -1596,4 +1596,23 @@ class Database implements LoggerAwareInterface $this->_HistoryPDO = null; } + + /** + * Enable or disables query buffering on the underlying PDO + * connection. + * + * @param bool $buffered - true if query buffering should be enabled + * + * @return void + */ + public function setBuffering(bool $buffered): void + { + if ($this->_PDO->setAttribute( + \PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, + $buffered + ) == false + ) { + throw new \DatabaseException("Could not use unbuffered queries"); + }; + } } diff --git a/php/libraries/Utility.class.inc b/php/libraries/Utility.class.inc index 305124143a9..075d81c7095 100644 --- a/php/libraries/Utility.class.inc +++ b/php/libraries/Utility.class.inc @@ -125,6 +125,23 @@ class Utility return $result; } + /** + * Returns list of sex options in the database + * + * @return array An associative array of sex values. + */ + static function getSexList(): array + { + $factory = NDB_Factory::singleton(); + $DB = $factory->database(); + + $query = "SELECT Name FROM sex"; + + $result = $DB->pselectCol($query, []); + + return array_combine($result, $result); + } + /** * Returns a list of sites in the database * diff --git a/raisinbread/RB_files/RB_physiological_annotation_archive.sql b/raisinbread/RB_files/RB_physiological_annotation_archive.sql deleted file mode 100644 index 4b151ea9537..00000000000 --- a/raisinbread/RB_files/RB_physiological_annotation_archive.sql +++ /dev/null @@ -1,5 +0,0 @@ -SET FOREIGN_KEY_CHECKS=0; -TRUNCATE TABLE `physiological_annotation_archive`; -LOCK TABLES `physiological_annotation_archive` WRITE; -UNLOCK TABLES; -SET FOREIGN_KEY_CHECKS=1; diff --git a/raisinbread/RB_files/RB_physiological_annotation_file.sql b/raisinbread/RB_files/RB_physiological_annotation_file.sql deleted file mode 100644 index a80a80a2a37..00000000000 --- a/raisinbread/RB_files/RB_physiological_annotation_file.sql +++ /dev/null @@ -1,5 +0,0 @@ -SET FOREIGN_KEY_CHECKS=0; -TRUNCATE TABLE `physiological_annotation_file`; -LOCK TABLES `physiological_annotation_file` WRITE; -UNLOCK TABLES; -SET FOREIGN_KEY_CHECKS=1; diff --git a/raisinbread/RB_files/RB_physiological_annotation_file_type.sql b/raisinbread/RB_files/RB_physiological_annotation_file_type.sql deleted file mode 100644 index 4a1f2f664c4..00000000000 --- a/raisinbread/RB_files/RB_physiological_annotation_file_type.sql +++ /dev/null @@ -1,7 +0,0 @@ -SET FOREIGN_KEY_CHECKS=0; -TRUNCATE TABLE `physiological_annotation_file_type`; -LOCK TABLES `physiological_annotation_file_type` WRITE; -INSERT INTO `physiological_annotation_file_type` (`FileType`, `Description`) VALUES ('json','JSON File Type, metadata for annotations'); -INSERT INTO `physiological_annotation_file_type` (`FileType`, `Description`) VALUES ('tsv','TSV File Type, contains information about each annotation'); -UNLOCK TABLES; -SET FOREIGN_KEY_CHECKS=1; diff --git a/raisinbread/RB_files/RB_physiological_annotation_instance.sql b/raisinbread/RB_files/RB_physiological_annotation_instance.sql deleted file mode 100644 index 7a5d15a8e08..00000000000 --- a/raisinbread/RB_files/RB_physiological_annotation_instance.sql +++ /dev/null @@ -1,5 +0,0 @@ -SET FOREIGN_KEY_CHECKS=0; -TRUNCATE TABLE `physiological_annotation_instance`; -LOCK TABLES `physiological_annotation_instance` WRITE; -UNLOCK TABLES; -SET FOREIGN_KEY_CHECKS=1; diff --git a/raisinbread/RB_files/RB_physiological_annotation_label.sql b/raisinbread/RB_files/RB_physiological_annotation_label.sql deleted file mode 100644 index 64168d79151..00000000000 --- a/raisinbread/RB_files/RB_physiological_annotation_label.sql +++ /dev/null @@ -1,28 +0,0 @@ -SET FOREIGN_KEY_CHECKS=0; -TRUNCATE TABLE `physiological_annotation_label`; -LOCK TABLES `physiological_annotation_label` WRITE; -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (1,NULL,'artifact','artifactual data'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (2,NULL,'motion','motion related artifact'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (3,NULL,'flux_jump','artifactual data due to flux jump'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (4,NULL,'line_noise','artifactual data due to line noise (e.g., 50Hz)'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (5,NULL,'muscle','artifactual data due to muscle activity'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (6,NULL,'epilepsy_interictal','period deemed interictal'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (7,NULL,'epilepsy_preictal','onset of preictal state prior to onset of epilepsy'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (8,NULL,'epilepsy_seizure','onset of epilepsy'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (9,NULL,'epilepsy_postictal','postictal seizure period'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (10,NULL,'epileptiform','unspecified epileptiform activity'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (11,NULL,'epileptiform_single','a single epileptiform graphoelement (including possible slow wave)'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (12,NULL,'epileptiform_run','a run of one or more epileptiform graphoelements'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (13,NULL,'eye_blink','Eye blink'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (14,NULL,'eye_movement','Smooth Pursuit / Saccadic eye movement'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (15,NULL,'eye_fixation','Fixation onset'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (16,NULL,'sleep_N1','sleep stage N1'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (17,NULL,'sleep_N2','sleep stage N2'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (18,NULL,'sleep_N3','sleep stage N3'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (19,NULL,'sleep_REM','REM sleep'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (20,NULL,'sleep_wake','sleep stage awake'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (21,NULL,'sleep_spindle','sleep spindle'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (22,NULL,'sleep_k-complex','sleep K-complex'); -INSERT INTO `physiological_annotation_label` (`AnnotationLabelID`, `AnnotationFileID`, `LabelName`, `LabelDescription`) VALUES (23,NULL,'scorelabeled','a global label indicating that the EEG has been annotated with SCORE.'); -UNLOCK TABLES; -SET FOREIGN_KEY_CHECKS=1; diff --git a/raisinbread/RB_files/RB_physiological_annotation_parameter.sql b/raisinbread/RB_files/RB_physiological_annotation_parameter.sql deleted file mode 100644 index 812f2463f90..00000000000 --- a/raisinbread/RB_files/RB_physiological_annotation_parameter.sql +++ /dev/null @@ -1,5 +0,0 @@ -SET FOREIGN_KEY_CHECKS=0; -TRUNCATE TABLE `physiological_annotation_parameter`; -LOCK TABLES `physiological_annotation_parameter` WRITE; -UNLOCK TABLES; -SET FOREIGN_KEY_CHECKS=1; diff --git a/raisinbread/RB_files/RB_physiological_annotation_rel.sql b/raisinbread/RB_files/RB_physiological_annotation_rel.sql deleted file mode 100644 index 362e9d22592..00000000000 --- a/raisinbread/RB_files/RB_physiological_annotation_rel.sql +++ /dev/null @@ -1,5 +0,0 @@ -SET FOREIGN_KEY_CHECKS=0; -TRUNCATE TABLE `physiological_annotation_rel`; -LOCK TABLES `physiological_annotation_rel` WRITE; -UNLOCK TABLES; -SET FOREIGN_KEY_CHECKS=1; diff --git a/shell.nix b/shell.nix index 01387ef043a..56c98d33119 100644 --- a/shell.nix +++ b/shell.nix @@ -1,10 +1,10 @@ { pkgs ? import {} }: let - php = pkgs.php82.withExtensions ({ enabled, all }: + php = pkgs.php83.withExtensions ({ enabled, all }: enabled ++ [ all.ast ]); in pkgs.mkShell { - buildInputs = with pkgs; [ php git nodejs php82Packages.composer ]; + buildInputs = with pkgs; [ php git nodejs php83Packages.composer ]; shellHook = '' php -v; diff --git a/src/Data/Query/SQLQueryEngine.php b/src/Data/Query/SQLQueryEngine.php index 5e2e0157d40..f58dda1ee9b 100644 --- a/src/Data/Query/SQLQueryEngine.php +++ b/src/Data/Query/SQLQueryEngine.php @@ -94,7 +94,8 @@ abstract protected function getFieldNameFromDict( * * @return string */ - abstract protected function getCorrespondingKeyField($fieldname); + abstract protected function getCorrespondingKeyField(\LORIS\Data\Dictionary\DictionaryItem $field); + abstract public function getCorrespondingKeyFieldType(\LORIS\Data\Dictionary\DictionaryItem $item) : string; /** * {@inheritDoc} @@ -159,42 +160,25 @@ public function getCandidateData( // Always required for candidateCombine $fields = ['c.CandID']; - $DBSettings = $this->loris->getConfiguration()->getSetting("database"); - - if (!$this->useBufferedQuery) { - $DB = new \PDO( - "mysql:host=$DBSettings[host];" - ."dbname=$DBSettings[database];" - ."charset=UTF8", - $DBSettings['username'], - $DBSettings['password'], - ); - if ($DB->setAttribute( - \PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, - false - ) == false - ) { - throw new \DatabaseException("Could not use unbuffered queries"); - }; - - $this->createTemporaryCandIDTablePDO( - $DB, - "searchcandidates", - $candidates, - ); - } else { - $DB = $this->loris->getDatabaseConnection(); - $this->createTemporaryCandIDTable($DB, "searchcandidates", $candidates); - } + $DB = $this->loris->getDatabaseConnection(); + + $this->createTemporaryCandIDTable($DB, "searchcandidates", $candidates); + + $DB->setBuffering($this->useBufferedQuery); + $sessionVariables = false; + $keyFields = []; foreach ($items as $dict) { $fields[] = $this->getFieldNameFromDict($dict) . ' as ' - . $dict->getName(); + . "`{$dict->getName()}`"; if ($dict->getScope() == 'session') { $sessionVariables = true; } + if ($dict->getCardinality()->__toString() === "many") { + $keyFields[] = $this->getCorrespondingKeyField($dict) . " as `{$dict->getName()}:key`"; + } } if ($sessionVariables) { @@ -205,6 +189,10 @@ public function getCandidateData( $fields[] = 's.ID as SessionID'; } } + if (!empty($keyFields)) { + $fields = array_merge($fields, $keyFields); + } + $query = 'SELECT ' . join(', ', $fields) . ' FROM'; $query .= ' ' . $this->getTableJoins(); @@ -221,9 +209,12 @@ public function getCandidateData( } $query .= 'AND s.Visit_label IN (' . join(",", $inset) . ')'; } + $whereCond = $this->getWhereConditions(); + if (!empty($whereCond)) { + $query .= ' AND ' . $this->getWhereConditions(); + } $query .= ' ORDER BY c.CandID'; - - $rows = $DB->prepare($query); + $rows = $DB->prepare($query); $result = $rows->execute($prepbindings); @@ -231,7 +222,14 @@ public function getCandidateData( throw new \Exception("Invalid query $query"); } - return $this->candidateCombine($items, $rows); + // Yield the generator ourself, so that when it's done we can restore the + // buffered query attribute + foreach ($this->candidateCombine($items, $rows) as $candid => $val) { + yield $candid => $val; + }; + + // Restore the default now that the generator has finished yielding. + $DB->setBuffering($this->useBufferedQuery, true); } /** @@ -287,7 +285,7 @@ protected function sqlOperator(Criteria $criteria) : string * * @return string */ - protected function sqlValue(Criteria $criteria, array &$prepbindings) : string + protected function sqlValue(DictionaryItem $dict, Criteria $criteria, array &$prepbindings) : string { static $i = 1; @@ -314,7 +312,12 @@ protected function sqlValue(Criteria $criteria, array &$prepbindings) : string } $prepname = ':val' . $i++; - $prepbindings[$prepname] = $criteria->getValue(); + if ($dict->getDataType() instanceof \LORIS\Data\Types\BooleanType) { + $val = $criteria->getValue(); + $prepbindings[$prepname] = !empty($val) && $val !== "false"; + } else { + $prepbindings[$prepname] = $criteria->getValue(); + } if ($criteria instanceof StartsWith) { return "CONCAT($prepname, '%')"; @@ -363,11 +366,13 @@ protected function getTableJoins() : string * Adds a where clause to the query based on converting Criteria * to SQL. */ - protected function addWhereCriteria(string $fieldname, Criteria $criteria, array &$prepbindings) + protected function addWhereCriteria(DictionaryItem $dict, Criteria $criteria, array &$prepbindings) { + + $fieldname = $this->getFieldNameFromDict($dict); $this->where[] = $fieldname . ' ' . $this->sqlOperator($criteria) . ' ' - . $this->sqlValue($criteria, $prepbindings); + . $this->sqlValue($dict, $criteria, $prepbindings); } /** @@ -407,7 +412,7 @@ protected function resetEngineState() * * @return */ - protected function candidateCombine(iterable $dict, iterable $rows) + protected function candidateCombine(iterable $dict, iterable &$rows) { $lastcandid = null; $candval = []; @@ -440,7 +445,6 @@ protected function candidateCombine(iterable $dict, iterable $rows) // Assert that the VisitLabel and SessionID are the same. assert($candval[$fname][$SID]['VisitLabel'] == $row['VisitLabel']); assert($candval[$fname][$SID]['SessionID'] == $row['SessionID']); - if ($field->getCardinality()->__toString() !== "many") { // It's not cardinality many, so ensure it's the same value. The // Query may have returned multiple rows with the same value as @@ -449,16 +453,17 @@ protected function candidateCombine(iterable $dict, iterable $rows) assert($candval[$fname][$SID]['value'] == $row[$fname]); } else { // It is cardinality many, so append the value. - // $key = $this->getCorrespondingKeyField($fname); - $key = $row[$fname . ':key']; - $val = [ - 'key' => $key, - 'value' => $row[$fname], - ]; - if (isset($candval[$fname][$SID]['values'][$key])) { - assert($candval[$fname][$SID]['values'][$key]['value'] == $row[$fname]); - } else { - $candval[$fname][$SID]['values'][$key] = $val; + // A null val in a cardinality many column means the row came from a left join + // and shouldn't be included (as opposed to a cardinality:optional where it + // means that the value was the value null) + $key = $row[$field->getName() . ':key']; + $val = $this->displayValue($field, $row[$fname]); + if ($key !== null && $val !== null) { + if (isset($candval[$fname][$SID]['values']['key'])) { + assert($candval[$fname][$SID]['values']['key'] == $val); + } else { + $candval[$fname][$SID]['values'][$key] = $val; + } } } } else { @@ -468,20 +473,27 @@ protected function candidateCombine(iterable $dict, iterable $rows) $candval[$fname][$SID] = [ 'VisitLabel' => $row['VisitLabel'], 'SessionID' => $row['SessionID'], - 'value' => $row[$fname], + 'value' => $this->displayValue($field, $row[$fname]), ]; } else { // It is many, so use an array - $key = $row[$fname . ':key']; - $val = [ - 'key' => $key, - 'value' => $row[$fname], - ]; - $candval[$fname][$SID] = [ - 'VisitLabel' => $row['VisitLabel'], - 'SessionID' => $row['SessionID'], - 'values' => [$key => $val], - ]; + $key = $row[$field->getName() . ':key']; + $val = $this->displayValue($field, $row[$fname]); + // A null val in a cardinality many column means the row came from a left join + // and shouldn't be included (as opposed to a cardinality:optional where it + // means that the value was the value null) + if ($key !== null && $val !== null) { + $candval[$fname]['keytype'] = $this->getCorrespondingKeyFieldtype($field); + + // This is just to get around PHPCS complaining about line + // length. + $sarray = [ + 'VisitLabel' => $row['VisitLabel'], + 'SessionID' => $row['SessionID'], + 'values' => [$key => $val], + ]; + $candval[$fname][$SID] = $sarray; + } } } } @@ -500,11 +512,21 @@ protected function candidateCombine(iterable $dict, iterable $rows) } } + private function displayValue(DictionaryItem $field, mixed $value) : mixed + { + // MySQL queries turn boolean columns into 0/1, so if it's a boolean dictionary + // item we need to convert it back to true/false + if ($field->getDataType() instanceof \LORIS\Data\Types\BooleanType) { + return !empty($value); + } + return $value; + } + /** * Create a temporary table containing the candIDs from $candidates using the * LORIS database connection $DB. */ - protected function createTemporaryCandIDTable(\Database $DB, string $tablename, array $candidates) + protected function createTemporaryCandIDTable(\Database $DB, string $tablename, array &$candidates) { // Put candidates into a temporary table so that it can be used in a join // clause. Directly using "c.CandID IN (candid1, candid2, candid3, etc)" is @@ -515,45 +537,16 @@ protected function createTemporaryCandIDTable(\Database $DB, string $tablename, CandID int(6) );" ); - $insertstmt = "INSERT INTO $tablename VALUES (" . join('),(', $candidates) . ')'; - $q = $DB->prepare($insertstmt); - $q->execute([]); - } - - /** - * Create a temporary table containing the candIDs from $candidates on the PDO connection - * $PDO. - * - * Note:LORIS Database connections and PDO connections do not share temporary tables. - */ - protected function createTemporaryCandIDTablePDO($PDO, string $tablename, array $candidates) - { - $query = "DROP TEMPORARY TABLE IF EXISTS $tablename"; - $result = $PDO->exec($query); - if ($result === false) { - throw new \DatabaseException( - "Could not run query $query" - ); - } + $insertstmt = "INSERT INTO $tablename VALUES (:CandID)"; - $query = "CREATE TEMPORARY TABLE $tablename ( - CandID int(6) - );"; - $result = $PDO->exec($query); - - if ($result === false) { - throw new \DatabaseException( - "Could not run query $query" - ); + $q = $DB->prepare($insertstmt); + foreach ($candidates as $candidate) { + $q->execute(['CandID' => $candidate]); } - - $insertstmt = "INSERT INTO $tablename VALUES (" . join('),(', $candidates) . ')'; - $q = $PDO->prepare($insertstmt); - $q->execute([]); } - protected $useBufferedQuery = false; + protected $useBufferedQuery = true; /** * Enable or disable MySQL query buffering by PHP. Disabling query @@ -584,14 +577,13 @@ protected function buildQueryFromCriteria( ) { $dict = $term->dictionary; $this->addWhereCriteria( - $this->getFieldNameFromDict($dict), + $dict, $term->criteria, $prepbindings ); if ($visitlist != null) { - $this->addTable('LEFT JOIN session s ON (s.CandID=c.CandID)'); - $this->addWhereClause("s.Active='Y'"); + $this->addTable("LEFT JOIN session s ON (s.CandID=c.CandID AND s.Active='Y')"); $inset = []; $i = count($prepbindings); foreach ($visitlist as $vl) { diff --git a/src/Http/DataIteratorBinaryStream.php b/src/Http/DataIteratorBinaryStream.php index d18d1a90429..01e6fcbf385 100644 --- a/src/Http/DataIteratorBinaryStream.php +++ b/src/Http/DataIteratorBinaryStream.php @@ -216,12 +216,12 @@ public function getContents() * stream_get_meta_data() function. * * @see http://php.net/manual/en/function.stream-get-meta-data.php - * @param string $key Specific metadata to retrieve. + * @param ?string $key Specific metadata to retrieve. * @return array|mixed|null Returns an associative array if no key is * provided. Returns a specific key value if a key is provided and the * value is found, or null if the key is not found. */ - public function getMetadata($key = null) + public function getMetadata(?string $key = null) { return null; } diff --git a/src/Http/FilesPassthroughEndpoint.php b/src/Http/FilesPassthroughEndpoint.php new file mode 100644 index 00000000000..3e1c6ace7cf --- /dev/null +++ b/src/Http/FilesPassthroughEndpoint.php @@ -0,0 +1,96 @@ +getURI()->getPath(); + $prefix = $this->getEndpointPrefix(); + $idx = strpos($url, $prefix); + $file = substr($url, $idx + strlen($prefix)); + switch ($request->getMethod()) { + case 'GET': + $this->doDownloadNotification($file); + $handler = new \LORIS\FilesDownloadHandler( + $this->getDownloadDirectory($this->loris->getConfiguration()) + ); + return $handler->handle($request->withAttribute("filename", $file)); + // FIXME: This should handle POST/PUT using the FilesUploadHandler + default: + return new \LORIS\Http\Response\JSON\MethodNotAllowed(['GET']); + } + } + + /** + * Return the download directory on the server's filesystem which the + * files are stored relative to. This is generally a configuration + * variable from LORIS. + * + * @param \NDB_Config $config The LORIS config object + * @return \SplFileInfo + */ + abstract protected function getDownloadDirectory(\NDB_Config $config): \SplFileInfo; + + /** + * getEndpointPrefix returns the portion of the endpoint (relative to the module) + * that must be stripped from the URL to get the filename. + * + * @return string + */ + abstract protected function getEndpointPrefix() : string; + + /** + * Define a stub loadResources so that we don't crash when the module + * handler calls it. + */ + public function loadResources( + \User $user, + ServerRequestInterface $request + ) : void { + } + + /** + * Send a notification for the download. + * + * @param string $file The filename being downloaded + * + * @return void + */ + protected function doDownloadNotification($file) + { + $downloadNotifier = new \NDB_Notifier( + $this->Module->getName(), + "download", + ["file" => $file] + ); + $downloadNotifier->notify(); + } +} diff --git a/src/Http/StringStream.php b/src/Http/StringStream.php index 8dd639f7266..12d13fd2105 100644 --- a/src/Http/StringStream.php +++ b/src/Http/StringStream.php @@ -257,7 +257,7 @@ public function getContents() * The keys returned are identical to the keys returned from PHP's * stream_get_meta_data() function. * - * @param string $key Specific metadata to retrieve. + * @param ?string $key Specific metadata to retrieve. * * @see http://php.net/manual/en/function.stream-get-meta-data.php * @@ -265,7 +265,7 @@ public function getContents() * provided. Returns a specific key value if a key is provided and the * value is found, or null if the key is not found. */ - public function getMetadata($key = null) + public function getMetadata(?string $key = null) { $metadata = stream_get_meta_data($this->stream); if ($key === null) { diff --git a/src/Router/BaseRouter.php b/src/Router/BaseRouter.php index ad68023ca07..ca0bd628f4f 100644 --- a/src/Router/BaseRouter.php +++ b/src/Router/BaseRouter.php @@ -115,7 +115,7 @@ public function handle(ServerRequestInterface $request) : ResponseInterface $baseurl = $uri->withPath($baseurl)->withQuery(""); $request = $request->withAttribute("baseurl", $baseurl->__toString()); - $factory->setBaseURL($baseurl); + $factory->setBaseURL((string )$baseurl); $module = $this->loris->getModule($modulename); $module->registerAutoloader(); @@ -137,7 +137,7 @@ public function handle(ServerRequestInterface $request) : ResponseInterface if (preg_match("/^([0-9]{6})$/", $components[0])) { $baseurl = $uri->withPath("")->withQuery(""); - $factory->setBaseURL($baseurl); + $factory->setBaseURL((string )$baseurl); if (count($components) == 1) { $request = $request ->withAttribute("baseurl", $baseurl->__toString()) diff --git a/src/StudyEntities/Candidate/Sex.php b/src/StudyEntities/Candidate/Sex.php index f7f3481af2d..07e8f413fbf 100644 --- a/src/StudyEntities/Candidate/Sex.php +++ b/src/StudyEntities/Candidate/Sex.php @@ -28,11 +28,7 @@ class Sex implements \JsonSerializable /* @var string */ public $value; - private const VALID_VALUES = array( - 'Male', - 'Female', - 'Other', - ); + private $validValues = []; /** * Calls validate() immediately. @@ -43,10 +39,12 @@ class Sex implements \JsonSerializable */ public function __construct(string $value) { - if (!self::validate($value)) { + $this->validValues = \Utility::getSexList(); + + if (!self::validate($value, $this->validValues)) { throw new \DomainException( 'The value is not valid. Must be one of: ' - . implode(', ', self::VALID_VALUES) + . implode(', ', array_values($this->validValues)) ); } $this->value = $value; @@ -55,13 +53,14 @@ public function __construct(string $value) /** * Ensures that the value is well-formed. * - * @param string $value The value to be validated + * @param string $value The value to be validated + * @param array $laidValues The restricted optional values of $value * * @return bool True if the value format is valid */ - public static function validate(string $value): bool + public static function validate(string $value, array $validValues): bool { - return in_array($value, self::VALID_VALUES, true); + return in_array($value, array_values($validValues), true); } /** diff --git a/test/unittests/CandidateTest.php b/test/unittests/CandidateTest.php index e844f9a8cec..548d15bb1ed 100644 --- a/test/unittests/CandidateTest.php +++ b/test/unittests/CandidateTest.php @@ -86,7 +86,7 @@ class CandidateTest extends TestCase /** * Test double for Database object * - * @var \Database | PHPUnit\Framework\MockObject\MockObject + * @phan-var \Database | PHPUnit\Framework\MockObject\MockObject */ private $_dbMock; @@ -130,7 +130,7 @@ protected function setUp(): void ]; $configMock = $this->getMockBuilder('NDB_Config')->getMock(); - $dbMock = $this->getMockBuilder('Database')->getMock(); + $dbMock = $this->getMockBuilder('\Database')->getMock(); '@phan-var \NDB_Config $configMock'; '@phan-var \Database $dbMock'; @@ -157,8 +157,7 @@ protected function setUp(): void 'RegistrationProjectID' => '1', 'ProjectTitle' => '', ]; - - $this->_candidate = new Candidate(); + $this->_candidate = new Candidate(); } /** @@ -183,7 +182,7 @@ protected function tearDown(): void */ public function testSelectRetrievesCandidateInfo() { - //$this->_setUpTestDoublesForSelectCandidate(); + $this->_setUpTestDoublesForSelectCandidate(); $this->_dbMock ->method('pselect') ->willReturn( @@ -558,11 +557,12 @@ public function testGetListOfVisitLabels() */ public function testGetValidCohortsReturnsAListOfCohorts() { + $this->_dbMock->method('pselectCol') + ->willReturn(['Male','Female','Other']); $cohorts = [ ['CohortID' => 1], ['CohortID' => 2] ]; - //$this->_setUpTestDoublesForSelectCandidate(); $this->_dbMock->expects($this->once()) ->method('pselectRow') ->willReturn($this->_candidateInfo); @@ -622,6 +622,8 @@ public function testGetValidCohortsReturnsEmptyArray(): void */ public function testGetCohortForMostRecentVisitReturnsMostRecentVisitLabel() { + $this->_dbMock->method('pselectCol') + ->willReturn(['Male','Female','Other']); $cohort = [ [ 'CohortID' => 1, @@ -665,6 +667,8 @@ public function testGetCohortForMostRecentVisitReturnsMostRecentVisitLabel() */ public function testGetCohortForMostRecentVisitReturnsNull() { + $this->_dbMock->method('pselectCol') + ->willReturn(['Male','Female','Other']); $cohort = []; $this->_dbMock->expects($this->once()) ->method('pselectRow') @@ -834,6 +838,8 @@ public function testGetAgeInDaysReturnsDays() */ public function testGetSessionIDForExistingVisit() { + $this->_setUpTestDoublesForSelectCandidate(); + $this->_dbMock->expects($this->once()) ->method('pselectRow') ->willReturn($this->_candidateInfo); @@ -1366,6 +1372,9 @@ private function _setUpTestDoublesForSelectCandidate() ->method('pselectRow') ->willReturn($this->_candidateInfo); + $this->_dbMock->method('pselectCol') + ->willReturn(['Male','Female','Other']); + $this->_configMock->method('getSetting') ->will($this->returnValueMap($this->_configMap)); } diff --git a/tools/CouchDB_Import_Demographics.php b/tools/CouchDB_Import_Demographics.php index 8aa236d2960..9deee7f5f14 100755 --- a/tools/CouchDB_Import_Demographics.php +++ b/tools/CouchDB_Import_Demographics.php @@ -48,7 +48,7 @@ class CouchDBDemographicsImporter ], 'Sex' => [ 'Description' => 'Candidate\'s biological sex', - 'Type' => "enum('Male', 'Female', 'Other')" + 'Type' => "varchar(255)" ], 'Site' => [ 'Description' => 'Site that this visit took place at', @@ -324,7 +324,7 @@ function _updateDataDict() if ($config->getSetting("useProband") === "true") { $this->Dictionary["Sex_proband"] = [ 'Description' => 'Proband\'s biological sex', - 'Type' => "enum('Male','Female', 'Other')" + 'Type' => "varchar(255)" ]; $this->Dictionary["Age_difference"] = [ 'Description' => 'Age difference between the candidate and ' .