diff --git a/Gemfile b/Gemfile index 54f28bf..243c202 100644 --- a/Gemfile +++ b/Gemfile @@ -45,6 +45,9 @@ gem 'exifr', '~> 1.3', '>= 1.3.9' gem "aws-sdk-s3", require: false +# Vessel for webcrawling +gem "ferrum" + group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] diff --git a/Gemfile.lock b/Gemfile.lock index e78ca24..ce4201f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -106,6 +106,11 @@ GEM erubi (1.10.0) execjs (2.8.1) exifr (1.3.9) + ferrum (0.15) + addressable (~> 2.5) + concurrent-ruby (~> 1.1) + webrick (~> 1.7) + websocket-driver (~> 0.7) ffi (1.15.4) globalid (1.0.0) activesupport (>= 5.0) @@ -234,6 +239,7 @@ GEM rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) + webrick (1.8.2) websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -254,6 +260,7 @@ DEPENDENCIES byebug capybara (>= 3.26) exifr (~> 1.3, >= 1.3.9) + ferrum image_processing (~> 1.12, >= 1.12.2) jbuilder (~> 2.7) listen (~> 3.3) diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 1ba37ce..7e5cb8a 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -26,6 +26,19 @@ img.flipped { transform: scaleX(-1); } +.GradeableImages { + .ImageThumbnail { + cursor: pointer; + } + .ImageFill { + width: 100%; + height: 100%; + } + .ImageFullscreen { + width: 100%; + } +} + // Style overrides body { @@ -72,3 +85,6 @@ body { text-decoration: none; } } +.list-unstyled { + margin-bottom: 0; +} diff --git a/app/controllers/grading_controller.rb b/app/controllers/grading_controller.rb index fa15e03..34d9fb3 100644 --- a/app/controllers/grading_controller.rb +++ b/app/controllers/grading_controller.rb @@ -1,5 +1,7 @@ class GradingController < ApplicationController + skip_before_action :verify_authenticity_token, only: [:grade] + def index @user_grading_set = current_user.user_grading_set_for(params[:grading_set_id]) if !@user_grading_set.image_complete? @@ -34,15 +36,7 @@ def grade ) end @user_grading_set_image.flipped = params[:flipped] == '1' - @user_grading_set_image.grading_data = params.permit(grading_data: [ - :photo_quality, - :is_everted, - :tf_grade, - :ti_grade, - :ts_grade, - :upper_lid_tt_grade, - :lower_lid_tt_grade - ])['grading_data'] + @user_grading_set_image.grading_data = JSON.parse(params[:grading_data]) @user_grading_set_image.save! flash.notice = "#{@user_grading_set.grading_set.name} image grade saved!" redirect_to action: 'index', grading_set_id: params[:grading_set_id] diff --git a/app/controllers/grading_sets_controller.rb b/app/controllers/grading_sets_controller.rb index 610b75b..04083e2 100644 --- a/app/controllers/grading_sets_controller.rb +++ b/app/controllers/grading_sets_controller.rb @@ -51,9 +51,11 @@ def create end def data - @grading_set = GradingSet.find params[:id] + # @grading_set = GradingSet.find params[:id] stream_csv_response filename: 'report.csv', - enumerator: @grading_set.csv_enumerator + enumerator: UserGradingSetImage.data_csv_enumerator({ + grading_sets: {id: params[:id]} + }) end def destroy @@ -96,16 +98,12 @@ def removeuser end def removeimage - @grading_set = GradingSet.find params[:id] - @image = Image.find params[:image_id] - @grading_set_image = GradingSetImage.where({ - image: @image, - grading_set: @grading_set - }).first + @grading_set_image = GradingSetImage.find params[:grading_set_image_id] + name = @grading_set_image.gradeable.name if @grading_set_image && @grading_set_image.destroy - flash.notice = "#{@image.filename} removed from grading set" + flash.notice = "#{name} removed from grading set" else - flash.alert = "Unable to remove #{@image.filename} from grading set" + flash.alert = "Unable to remove #{name} from grading set" end redirect_to action: 'show' end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index cbf7de3..abf7f70 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -5,11 +5,12 @@ class HomeController < ApplicationController skip_before_action :require_login def index - if current_user - @user_grading_sets = current_user.user_grading_sets - .order('created_at desc') - .limit(3) - end + redirect_to controller: 'dashboard' + # if current_user + # @user_grading_sets = current_user.user_grading_sets + # .order('created_at desc') + # .limit(3) + # end end end diff --git a/app/controllers/image_sets_controller.rb b/app/controllers/image_sets_controller.rb new file mode 100644 index 0000000..8a6c8a1 --- /dev/null +++ b/app/controllers/image_sets_controller.rb @@ -0,0 +1,112 @@ +class ImageSetsController < ApplicationController + include CsvStreamable + + before_action :require_image_viewer, only: [:index, :show] + before_action :require_admin, only: [:addtogradingset] + + skip_before_action :verify_authenticity_token, only: [:addtogradingset, :gradingdata, :metadata] + + def index + @pagesize = 50 + @limit = (params[:limit] || @pagesize).to_i + @offset = (params[:offset] || 0).to_i + @image_sets = search_image_sets + .includes(:image_set_images, :grading_sets) + .limit(@limit).offset(@offset) + @image_sets_count = search_image_sets.count + @image_sources = ImageSource.active.order('name desc') + @metadata_keys = ImageSet.all_metadata_keys + respond_to do |format| + format.html + format.json { render json: {images: @images} } + end + + end + + def show + @image_set = ImageSet.find params[:id] + end + + def new + + end + + def update + + end + + def create + + end + + def destroy + + end + + def set_images + + end + + def download + + end + + def metadata + stream_csv_response filename: 'metadata.csv', + enumerator: ImageSet.csv_metadata_enumerator(search_image_set_ids) + end + + + def addtogradingset + @grading_set = GradingSet.find params[:grading_set_id] + unless @grading_set + return redirect_to({ action: 'index' }, flash: { error: "No such grading set" }) + end + if params[:image_set_id_all] == 'all' + @image_set_ids = search_image_sets.select(:id).map(&:id) + @count = GradingSetImage.upsert_all(@image_set_ids.map {|image_set_id| + { + gradeable_id: image_set_id, + gradeable_type: 'ImageSet', + grading_set_id: @grading_set.id, + created_at: Time.zone.now, + updated_at: Time.zone.now + } + }, unique_by: [:grading_set_id, :gradeable_id], returning: [:id]).count + else + @count = GradingSetImage.upsert_all(params[:image_set_ids].map {|image_set_id| + { + gradeable_id: image_set_id, + gradeable_type: 'ImageSet', + grading_set_id: @grading_set.id, + created_at: Time.zone.now, + updated_at: Time.zone.now + } + }, unique_by: [:grading_set_id, :gradeable_id], returning: [:id]).count + end + return redirect_to({ action: 'index' }, flash: { + success: "Successfully added #{@count} image sets to #{@grading_set.name}" + }) + end + + def gradingdata + stream_csv_response filename: 'image_set_gradingdata.csv', + enumerator: UserGradingSetImage.data_csv_enumerator({ + image_sets: params + }) + end + + private + + def image_set_params + params.require(:image_set).permit(:name, :image_source_id, :metadata) + end + + def search_image_sets + ImageSet.search(params) + end + + def search_image_set_ids + search_image_sets.select(:id).map(&:id) + end +end \ No newline at end of file diff --git a/app/controllers/image_sources_controller.rb b/app/controllers/image_sources_controller.rb index a8f4686..7aa49ab 100644 --- a/app/controllers/image_sources_controller.rb +++ b/app/controllers/image_sources_controller.rb @@ -76,7 +76,12 @@ def image_urls private def image_source_params - params.require(:image_source).permit(:name, :active) + params.require(:image_source).permit( + :name, + :active, + :create_image_sets, + :create_image_sets_metadata_field + ) end def metadata_params diff --git a/app/controllers/images_controller.rb b/app/controllers/images_controller.rb index ccbc826..3085f44 100644 --- a/app/controllers/images_controller.rb +++ b/app/controllers/images_controller.rb @@ -6,7 +6,7 @@ class ImagesController < ApplicationController before_action :require_admin, only: [:addtogradingset] skip_before_action :verify_authenticity_token, - only: [:addtogradingset, :metadata, :exif_data] + only: [:addtogradingset, :metadata, :exif_data, :gradingdata] skip_forgery_protection only: [:index] @@ -14,8 +14,8 @@ def index @pagesize = 50 @limit = (params[:limit] || @pagesize).to_i @offset = (params[:offset] || 0).to_i - @images = search_images.limit(@limit).offset(@offset) - @images_count = search_images.count + @images = Image.search(params).limit(@limit).offset(@offset) + @images_count = Image.search(params).count @image_sources = ImageSource.active.order('name desc') @metadata_keys = Image.all_metadata_keys respond_to do |format| @@ -61,24 +61,26 @@ def addtogradingset return redirect_to({ action: 'index' }, flash: { error: "No such grading set" }) end if params[:image_id_all] == 'all' - @image_ids = search_images.select(:id).map(&:id) + @image_ids = Image.search(params).select(:id).map(&:id) @count = GradingSetImage.upsert_all(@image_ids.map {|image_id| { - image_id: image_id, + gradeable_id: image_id, + gradeable_type: 'Image', grading_set_id: @grading_set.id, created_at: Time.zone.now, updated_at: Time.zone.now } - }, unique_by: [:grading_set_id, :image_id], returning: [:id]).count + }, unique_by: [:grading_set_id, :gradeable_id], returning: [:id]).count else @count = GradingSetImage.upsert_all(params[:image_ids].map {|image_id| { - image_id: image_id, + gradeable_id: image_id, + gradeable_type: 'Image', grading_set_id: @grading_set.id, created_at: Time.zone.now, updated_at: Time.zone.now } - }, unique_by: [:grading_set_id, :image_id], returning: [:id]).count + }, unique_by: [:grading_set_id, :gradeable_id], returning: [:id]).count end return redirect_to({ action: 'index' }, flash: { success: "Successfully added #{@count} images to #{@grading_set.name}" @@ -87,12 +89,12 @@ def addtogradingset def metadata stream_csv_response filename: 'metadata.csv', - enumerator: Image.csv_metadata_enumerator(search_image_ids) + enumerator: Image.csv_metadata_enumerator(search_image_ids(params)) end def exif_data stream_csv_response filename: 'exif_data.csv', - enumerator: Image.csv_exif_data_enumerator(search_image_ids) + enumerator: Image.csv_exif_data_enumerator(search_image_ids(params)) end def download @@ -100,6 +102,13 @@ def download @image_source_id = params[:image_source_id] end + def gradingdata + stream_csv_response filename: 'image_gradingdata.csv', + enumerator: UserGradingSetImage.data_csv_enumerator({ + images: params + }) + end + # def update # @image = Image.find params[:id] # respond_to do |format| @@ -122,44 +131,8 @@ def new_image_params ) end - def search_images - wheres = ["1=1"] - wheres_params = {} - joins = [] - unless params[:metadata_key].blank? - safe_key = params[:metadata_key].gsub("'", "") # remove single quotes - wheres << "images.metadata->>'#{safe_key}' like :metadata_value" - wheres_params[:metadata_value] = "%#{params[:metadata_value]}%" - end - unless params[:image_ids].blank? - wheres << 'images.id in (:image_ids)' - wheres_params[:image_ids] = params[:image_ids] - end - unless params[:filename].blank? - wheres << 'filename ilike :filename' - wheres_params[:filename] = "%#{params[:filename]}%" - end - unless params[:image_source_id].blank? - wheres << 'image_source_id = :image_source_id' - wheres_params[:image_source_id] = params[:image_source_id] - end - unless params[:image_source].blank? - joins << :image_source - wheres << 'image_sources.name ilike :image_source' - wheres_params[:image_source] = "%#{params[:image_source]}%" - end - unless params[:grading_set].blank? - joins << :grading_sets - wheres << 'grading_sets.name ilike :grading_set' - wheres_params[:grading_set] = "%#{params[:grading_set]}%" - end - Image.active.joins(joins) - .order("images.filename asc") - .where(wheres.join(" and "), wheres_params) - end - - def search_image_ids - search_images.select(:id).map(&:id) + def search_image_ids(params) + Image.search(params).select(:id).map(&:id) end end diff --git a/app/controllers/metadata_controller.rb b/app/controllers/metadata_controller.rb index 98a46dc..385ac57 100644 --- a/app/controllers/metadata_controller.rb +++ b/app/controllers/metadata_controller.rb @@ -8,26 +8,49 @@ def index end def update - @image = Image.where( - image_source_id: params[:image_source_id], - filename: params[:filename] - ).first - unless @image - render json: {filename: params[:filename], error: 'Image not found'}, staus: :unprocessable_entity + if params[:type] == 'image_set' + @entity = image_set_by_params + entity_name = @entity&.name else - @image.metadata = params[:metadata] - if @image.save - render json: {filename: params[:filename], success: true} + @entity = image_by_params + entity_name = @entity&.filename + end + unless @entity + render json: {filename: entity_name, error: "#{params[:type] == 'image_set' ? 'Image Set' : 'Image'} not found"}, staus: :unprocessable_entity + else + if @entity.update_metadata metadata_params[:metadata], params[:merge_metadata], !!params[:id] + render json: {filename: entity_name, success: true} else - render json: {filename: params[:filename], error: @image.errors}, staus: :unprocessable_entity + render json: {filename: entity_name, error: @entity.errors}, staus: :unprocessable_entity end end end private - def metadata_params - params.require(:filename, metadata: []) + def image_by_params + if params[:id] + Image.find(params[:id]) + else + Image.where( + image_source_id: params[:image_source_id], + filename: params[:filename] || params[:name] + ).first + end end + def image_set_by_params + if params[:id] + ImageSet.find(params[:id]) + else + ImageSet.where( + image_source_id: params[:image_source_id], + name: params[:filename] || params[:name] + ).first + end + end + + def metadata_params + params.permit(metadata: {}) + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 68e92ae..e104c62 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -8,4 +8,12 @@ def grading_set_link grading_set end end + def gradeable_url gradeable + if gradeable.class == Image + url_for controller: 'images', action: 'show', id: gradeable.id + elsif gradeable.class == ImageSet + url_for controller: 'image_sets', action: 'show', id: gradeable.id + end + end + end diff --git a/app/javascript/packs/grading.jsx b/app/javascript/packs/grading.jsx new file mode 100644 index 0000000..6078925 --- /dev/null +++ b/app/javascript/packs/grading.jsx @@ -0,0 +1,38 @@ +import React, { useEffect, useState } from 'react' +import ReactDOM from 'react-dom' + +import { FGSGrading } from './grading/FGSGrading' +import { TrachomaGrading } from './grading/TrachomaGrading' + +const Grading = ({ + gradingType, + submitUrl, + gradingSetImage +}) => { + + return ( + <> + {gradingType === 'FGSGrading' && + + } + {gradingType === 'TrachomaGrading' && + + } + + ) +} + +document.addEventListener('DOMContentLoaded', () => { + const el = document.getElementById('react-app-container') + ReactDOM.render( + , + el.appendChild(document.createElement('div')) + ) +}) \ No newline at end of file diff --git a/app/javascript/packs/grading/FGSGrading.jsx b/app/javascript/packs/grading/FGSGrading.jsx new file mode 100644 index 0000000..57fdae6 --- /dev/null +++ b/app/javascript/packs/grading/FGSGrading.jsx @@ -0,0 +1,285 @@ +import React, { useEffect, useRef, useState } from 'react' +import ReactDOM from 'react-dom' + +import { GradeableImages } from './GradeableImages' +import { GradeableQuestionnaire } from './GradeableQuestionnaire' + +const selected = (values, key, value) => { + return values[key] && ( + (Array.isArray(values[key]) && values[key].indexOf(value) > -1) || + (values[key] === value) + ) + +} + +const Options = { + image: [ + {value: '1', label: 'Yes'}, + {value: '-1', label: 'No, not available'}, + {value: '0', label: 'No, unclear image'} + ], + yesno: [ + {value: '1', label: 'Yes'}, + {value: '0', label: 'No'} + ], + location: [ + {value: 'left_lateral_fornix', label: 'Left lateral fornix'}, + {value: 'right_lateral_fornix', label: 'Right lateral fornix'}, + {value: 'anterior_fornix', label: 'Anterior fornix'}, + {value: 'posterior_fornix', label: 'Posterior fornix'}, + {value: 'cervix_q1', label: 'Cervix Q1'}, + {value: 'cervix_q2', label: 'Cervix Q2'}, + {value: 'cervix_q3', label: 'Cervix Q3'}, + {value: 'cervix_q4', label: 'Cervix Q4'}, + {value: 'vaginal_walls', label: 'Vaginal walls'} + ], + sti: [ + {value: 'suspected_bacterial_vaginosis', label: 'Suspected Bacterial Vaginosis'}, + {value: 'suspected_trichomonas', label: 'Suspected Trichomonas'}, + {value: 'candidiasis', label: 'Candidiasis'}, + {value: 'suspected_gonorrhoea', label: 'Suspected Gonorrhoea'}, + {value: 'suspected_chlamydia', label: 'Suspected Chlamydia'}, + {value: 'suspected_herpes', label: 'Suspected Herpes'}, + {value: 'genital_warts', label: 'Genital Warts'}, + {value: 'chancroid', label: 'Chancroid'} + ], + repro: [ + {value: 'buboes', label: 'Buboes'}, + {value: 'lymphogranuloma', label: 'Lymphogranuloma'}, + {value: 'scabies', label: 'Scabies'}, + {value: 'crabs', label: 'Crabs'}, + {value: 'polyp', label: 'Polyp'}, + {value: 'ectropion', label: 'Ectropion'}, + {value: 'nabothian_cyst', label: 'Nabothian Cyst'}, + {value: 'prolapsed_uterus', label: 'Prolapsed Uterus'}, + {value: 'other_please_list', label: 'Other: please list'} + ], + sti_repro: [ + {value: '1', label: 'Cervicitis'}, + {value: '2', label: 'Suspected Trichomonas'}, + {value: '3', label: 'Candidiasis'}, + {value: '4', label: 'Suspected Herpes'}, + {value: '5', label: 'Genital Warts'}, + {value: '6', label: 'Chancroid'}, + {value: '7', label: 'Buboes'}, + {value: '8', label: 'Lymphogranuloma'}, + {value: '9', label: 'Scabies'}, + {value: '10', label: 'Crabs'}, + {value: '11', label: 'Polyp'}, + {value: '12', label: 'Ectropion'}, + {value: '13', label: 'Nabothian Cyst'}, + {value: '14', label: 'Prolapsed Uterus'}, + {value: '95', label: 'Other: please list'} + ] +} +const Groups = [ + { + name: 'image_quality', + label: 'Image Quality' + }, { + name: 'exam_findings', + label: 'FGS Examination Findings', + relevant: ({cervical_images_assessed, vaginal_wall_images_assessed}) => ( + cervical_images_assessed === '1' || vaginal_wall_images_assessed === '1' + ) + }, { + name: 'results', + label: 'Results', + relevant: ({cervical_images_assessed, vaginal_wall_images_assessed}) => ( + cervical_images_assessed === '1' || vaginal_wall_images_assessed === '1' + ) + } +] +const Questions = [ + { + group: 'image_quality', + type: 'select_one', + options: Options.image, + name: 'cervical_images_assessed', + label: 'Were cervical images able to be assessed?', + required: true + }, { + group: 'image_quality', + type: 'text', + name: 'cervical_image_comments', + label: 'Please enter any comments about cervical image quality.', + required: false + }, { + group: 'image_quality', + type: 'select_one', + options: Options.image, + name: 'vaginal_wall_images_assessed', + label: 'Were vaginal wall images able to be assessed?', + required: true + }, { + group: 'image_quality', + type: 'text', + name: 'image_comments', + label: 'Please enter any comments about vaginal wall image quality.', + required: false + }, { + group: 'exam_findings', + type: 'select_one', + options: Options.yesno, + name: 'grainy_sandy_patches', + label: 'Grainy sandy patches', + required: true + }, { + group: 'exam_findings', + type: 'select_multiple', + options: Options.location, + name: 'location_grainy_sandy_patches', + label: 'Specify Location: Grainy Sandy Patches', + relevant: values => values.grainy_sandy_patches === '1', + required: true + }, { + group: 'exam_findings', + type: 'select_one', + options: Options.yesno, + name: 'homogeneous_yellow_patches', + label: 'Homogeneous yellow patches', + required: true + }, { + group: 'exam_findings', + type: 'select_multiple', + options: Options.location, + name: 'location_homogeneous_yellow', + label: 'Specify Location: Homogeneous Yellow Patches', + relevant: values => values.homogeneous_yellow_patches === '1', + required: true + }, { + group: 'exam_findings', + type: 'select_one', + options: Options.yesno, + name: 'rubbery_papules', + label: 'Rubbery papules', + required: true + }, { + group: 'exam_findings', + type: 'select_multiple', + options: Options.location, + name: 'location_rubbery_papules', + label: 'Specify Location: Rubbery papules', + relevant: values => values.rubbery_papules === '1', + required: true + }, { + group: 'exam_findings', + type: 'select_one', + options: Options.yesno, + name: 'abnormal_blood_vessels', + label: 'Abnormal blood vessels', + required: true + }, { + group: 'exam_findings', + type: 'select_multiple', + options: Options.location, + name: 'location_abnormal_blood_vessel', + label: 'Specify Location: Abnormal blood vessels', + relevant: values => values.abnormal_blood_vessels === '1', + required: true + }, { + group: 'results', + type: 'select_one', + options: Options.yesno, + name: 'fgs_status', + label: 'Does the patient have FGS?', + constraint: (value, values) => { + return ( + ( + selected(values, 'grainy_sandy_patches', '1') || + selected(values, 'homogeneous_yellow_patches', '1') || + selected(values, 'rubbery_papules', '1') || + selected(values, 'abnormal_blood_vessels', '1') + ) && + value === '1' + ) || + ( + ( + selected(values, 'grainy_sandy_patches', '0') && + selected(values, 'homogeneous_yellow_patches', '0') && + selected(values, 'rubbery_papules', '0') && + selected(values, 'abnormal_blood_vessels', '0') + ) && + value === '0' + ); + }, + constraint_message: "Must select: 'Yes' if patient has any of: grainy sandy patches, homogeneous yellow patches, rubbery papules, abnormal blood vessels, or must select 'No' if none of those conditions are present.", + required: true + }, { + group: 'results', + type: 'select_one', + options: Options.yesno, + name: 'sus_sti_repro', + label: 'Does the patient have any suspected reproductive health problem, includings STIs?', + required: true + }, { + group: 'results', + type: 'select_multiple', + options: Options.sti_repro, + name: 'sti_repro_list', + label: 'Specify suspected reproductive health problem', + relevant: values => values.sus_sti_repro === '1', + required: true + }, { + group: 'results', + type: 'text', + name: 'repro_other_desc', + label: 'Please list other', + relevant: values => ( + selected(values, 'sti_repro_list', '95') + ), + required: true + }, { + group: 'results', + type: 'text', + name: 'additional_comments', + label: 'Please enter any additional comments', + required: false + } +]; + + +export const FGSGrading = ({ + authenticityToken, + gradingSetImage, + submitUrl +}) => { + + const [gradingData, setGradingData] = useState({}); + const formRef = useRef(null); + const gradingDataInputRef = useRef(null); + + const setCurrentValues = (values) => { + setGradingData(values) + gradingDataInputRef.current.value = JSON.stringify(values) + formRef.current.submit() + } + + return ( +
+
+
+ +
+
+ +
+
+
+ + +
+
+ ) +} \ No newline at end of file diff --git a/app/javascript/packs/grading/GradeableImages.jsx b/app/javascript/packs/grading/GradeableImages.jsx new file mode 100644 index 0000000..e38268d --- /dev/null +++ b/app/javascript/packs/grading/GradeableImages.jsx @@ -0,0 +1,98 @@ +import React, { useEffect, useState } from 'react' + +import { ImageThumbnail } from './ImageThumbnail' + +export const GradeableImages = ({ + gradingSetImage +}) => { + + const [zoomedIndex, setZoomedIndex] = useState(); + + + const escFunction = () => setZoomedIndex(); + + const getZoomedImage = () => { + return gradingSetImage.images_for_grading[zoomedIndex] + } + + const hasPrev = (index) => { + return index - 1 >= 0; + } + + const hasNext = (index) => { + return index + 1 < gradingSetImage.images_for_grading.length; + } + + let els = []; + for (let i = 0; i < gradingSetImage.images_for_grading.length; i++) { + const image = gradingSetImage.images_for_grading[i]; + els.push( +
setZoomedIndex(i)} + > + +
+ ); + } + + useEffect(() => { + document.addEventListener("keydown", escFunction, false); + return () => { + document.removeEventListener("keydown", escFunction, false); + }; + }, [escFunction]); + + + return ( +
+ {typeof zoomedIndex != 'undefined' && +
+
+
+
+

+ {getZoomedImage().filename} +

+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+ } +
+ {els} +
+
+ ) +} \ No newline at end of file diff --git a/app/javascript/packs/grading/GradeableQuestionnaire.jsx b/app/javascript/packs/grading/GradeableQuestionnaire.jsx new file mode 100644 index 0000000..a806d45 --- /dev/null +++ b/app/javascript/packs/grading/GradeableQuestionnaire.jsx @@ -0,0 +1,119 @@ +import React, { useEffect, useState, useRef } from 'react' + +import { Question } from './Question' + +export const GradeableQuestionnaire = ({ + groups, + questions, + gradeable, + values, + setValues +}) => { + + const [inputValues, setInputValues] = useState(values); + const elRef = useRef() + + const getQuestionGroup = (question) => { + if (!question.group) return null; + for (let i = 0; i < groups.length; i++) { + if (question.group === groups[i].name) return groups[i]; + } + } + + // Question states: + // answered, skipped, current + const getQuestionState = (question) => { + // If question is in a group check group relevance first: + const group = getQuestionGroup(question); + if (group && group.relevant && !group.relevant(inputValues) ) { + return 'skipped'; + } + + if (question.relevant && !question.relevant(inputValues)) { + return 'skipped'; + } else if (typeof inputValues[question.name] != 'undefined') { + return 'answered'; + } else { + return 'current'; + } + } + + const getCurrentQuestionData = () => { + let currentQuestions = []; + for (let i = 0; i < questions.length; i++) { + const state = getQuestionState(questions[i]); + if (state != 'skipped') { + currentQuestions.push({ + question: questions[i], + state: state + }) + if (state === 'current') break; + } + } + return currentQuestions + } + + const getCurrentQuestions = () => { + const currentQuestions = getCurrentQuestionData(); + let groupTitle = null; + + let els = [] + for (let i = 0; i < currentQuestions.length; i++) { + const prevQuestion = i > 0 ? currentQuestions[i-1].question : null; + const question = currentQuestions[i].question; + const state = currentQuestions[i].state; + const group = getQuestionGroup(question); + const last = state != 'current' && i == currentQuestions.length - 1; + els.push( + { + let newValues = {...inputValues}; + newValues[question.name] = value; + setInputValues(newValues); + if (last) setValues(inputValues); + }} + onCancel={prevQuestion ? (value) => { + let newValues = {...inputValues}; + delete newValues[prevQuestion.name]; + setInputValues(newValues); + } : null} + /> + ) + if (group && group.label && group.label != groupTitle) { + groupTitle = group.label; + } + } + return els; + } + + const confirmNavAway = () => { + const currentQuestions = getCurrentQuestionData(); + const lastIndex = currentQuestions.length - 1; + const last = currentQuestions[lastIndex].state != 'current'; + if (!last) { + return "Are you sure you want to navigate away from this page?\n\nAny responses entered will not be saved.\n\nPress OK to continue or Cancel to stay on the current page."; + } + } + + useEffect(() => { + window.onbeforeunload = confirmNavAway; + return () => { window.onbeforeunload = null; }; + }, [confirmNavAway]); + + useEffect(() => { + elRef.current?.scrollIntoView({block: "end", inline: "nearest"}) + }, [inputValues]) + + return ( +
+ {getCurrentQuestions()} +
+ ); + +} \ No newline at end of file diff --git a/app/javascript/packs/grading/ImageThumbnail.jsx b/app/javascript/packs/grading/ImageThumbnail.jsx new file mode 100644 index 0000000..66beafe --- /dev/null +++ b/app/javascript/packs/grading/ImageThumbnail.jsx @@ -0,0 +1,14 @@ +import React, { useEffect, useState } from 'react' + +export const ImageThumbnail = ({ + image +}) => { + + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/app/javascript/packs/grading/Question.jsx b/app/javascript/packs/grading/Question.jsx new file mode 100644 index 0000000..dc9f6ae --- /dev/null +++ b/app/javascript/packs/grading/Question.jsx @@ -0,0 +1,194 @@ +import React, { useEffect, useState } from 'react' + +const TYPES = { + TEXT: 'text', + SELECT_ONE: 'select_one', + SELECT_MULTIPLE: 'select_multiple' +}; + +const LEFT_COLS = 5; +const RIGHT_COLS = 7; + +// Question attributes: +// { +// group, +// name, +// type, +// label, +// options, +// hint, +// relevant, +// constraint, +// constrain_message, +// required +// } + +export const Question = ({ + value, + values, + state, + last, + question, + groupTitle, + labelClassName, + inputClassName, + onCancel, + onSubmit +}) => { + + const [editingValue, setEditingValue] = useState(value || + question.type === TYPES.SELECT_MULTIPLE ? [] : '' + ); + const [error, setError] = useState(); + + const setMultipleValue = (value, checked) => { + if (checked && editingValue.indexOf(value) < 0) { + setEditingValue(editingValue.concat([value])) + } else if (!checked && editingValue.indexOf(value) > -1) { + setEditingValue(editingValue.filter(v => v != value)) + } + } + + const submit = () => { + setError(); + if (question.constraint) { + if (!question.constraint(editingValue, values)) { + setError(question.constraint_message); + return; + } + } + if (question.required && (!editingValue || /^\s*$/.test(editingValue))) { + setError("A response is required."); + return; + } + onSubmit(editingValue); + } + + useEffect(() => { + if (question.type === TYPES.SELECT_ONE && + editingValue && + editingValue != '' && + editingValue != value + ) { + submit(); + } + }, [editingValue]) + + let el = Question type "{question.type}" not found.; + if (question.type === TYPES.TEXT) { + el = ( +
+ +
+ setEditingValue(e.target.value)} + onKeyPress={e => {if (e.key === 'Enter') submit();} } + /> +
+
+ ) + } else if (question.type === TYPES.SELECT_ONE) { + el = ( +
+ +
+ +
+
+ ) + } else if (question.type === TYPES.SELECT_MULTIPLE) { + el = ( +
+ +
+ {question.options.map(({value, label}) => ( +
+ -1} + id={`${question.name}.${value}`} + onChange={e => setMultipleValue(value, e.target.checked)} + /> + +
+ ))} +
+
+ ) + } + + return ( +
+ {groupTitle && +
+
+
{groupTitle}
+
+
+ } + {el} + {error && +
+ {error} +
+ } + {(state === 'current' || last) && ( +
+
+ + +
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/app/javascript/packs/grading/TrachomaGrading.jsx b/app/javascript/packs/grading/TrachomaGrading.jsx new file mode 100644 index 0000000..e3a6c82 --- /dev/null +++ b/app/javascript/packs/grading/TrachomaGrading.jsx @@ -0,0 +1,13 @@ +import React, { useEffect, useState } from 'react' +import ReactDOM from 'react-dom' + +export const TrachomaGrading = ({ + userGradingSet +}) => { + + return ( +
+ Trachoma Grading +
+ ) +} \ No newline at end of file diff --git a/app/javascript/packs/image_scripts.jsx b/app/javascript/packs/image_scripts.jsx index 54611d1..60ff0d3 100644 --- a/app/javascript/packs/image_scripts.jsx +++ b/app/javascript/packs/image_scripts.jsx @@ -60,6 +60,11 @@ $(function () { $("input[name='image_ids[]'").prop('checked', e.target.checked) $("input[name='image_ids[]'").prop('disabled', e.target.checked) }); + // Handle imageid-all checked/unchecked + $("input[name='image_set_id_all']").on('change', function(e) { + $("input[name='image_set_ids[]'").prop('checked', e.target.checked) + $("input[name='image_set_ids[]'").prop('disabled', e.target.checked) + }); // Handle page links $("a.page-link").on('click', function(e) { diff --git a/app/javascript/packs/metadata_upload.jsx b/app/javascript/packs/metadata_upload.jsx index 9839d31..b152fb4 100644 --- a/app/javascript/packs/metadata_upload.jsx +++ b/app/javascript/packs/metadata_upload.jsx @@ -9,12 +9,20 @@ const doUpload = async ({ authenticityToken, imageSourceId, filename, + id, + name, + type, + merge_metadata, metadata }) => { const result = await post('/metadata', { authenticity_token: authenticityToken, + type: type, + id: id, image_source_id: imageSourceId, filename: filename, + name: name, + merge_metadata: merge_metadata, metadata: metadata }) return result.data @@ -22,28 +30,28 @@ const doUpload = async ({ const getMetadata = ({headers, row}) => { let metadata = {} - for (let i = 1; i < headers.length; i++) { + for (let i = 0; i < headers.length; i++) { metadata[headers[i]] = row[i] } return metadata } -const Result = ({filename, success, error}) => ( +const Result = ({filename, name, success, error}) => ( <> {success && <> Success   - {filename} + {filename || name} } {!success && <> Error   - {filename} + {filename || name}   - {error} + {(typeof error === 'string' && error) || JSON.stringify(error)} } @@ -56,6 +64,8 @@ const MetadataUpload = ({ }) => { const [sourceId, setSourceId] = useState(imageSourceId || -1) + const [type, setType] = useState('image') + const [mergeMetadata, setMergeMetadata] = useState(true) const [data, setData] = useState(null) const [uploading, setUploading] = useState(false) @@ -64,6 +74,9 @@ const MetadataUpload = ({ const [results, setResults] = useState([]) const [finished, setFinished] = useState(false) + const newUpload = () => { + window.location.reload() + } useEffect(() => { const doEffect = async () => { @@ -71,12 +84,17 @@ const MetadataUpload = ({ const headers = data[0] for (let i = 1; i < data.length; i++) { setCurrent(i) + const metadata = getMetadata({headers, row: data[i]}) const result = await doUpload({ authenticityToken, + type, active: '1', + id: metadata['id'], imageSourceId: sourceId, - filename: data[i][0], - metadata: getMetadata({headers, row: data[i]}) + filename: metadata['filename'], + name: metadata['name'], + merge_metadata: mergeMetadata, + metadata: metadata }) results.push(result) setResults(results) @@ -91,21 +109,54 @@ const MetadataUpload = ({ <> {!uploading &&
-
-
diff --git a/app/views/image_sets/index.html.erb b/app/views/image_sets/index.html.erb new file mode 100644 index 0000000..df9516d --- /dev/null +++ b/app/views/image_sets/index.html.erb @@ -0,0 +1,273 @@ +<% + label_class = "col-lg-3 col-md-4 fw-bold text-md-end" + data_class = "col-lg-9 col-md-8 " +%> + + + +<%= form_with url: url_for(controller: 'image_sets'), + method: :get, + local: true, + id: 'search-form', + class: 'row mb-3 gy-2 gx-3 align-items-center' do |form| +%> + + +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+
+ + +
+ + <% if current_user.admin? %> + + + <% end %> + +
+ + + + + + + + + <% @image_sets.each do |image_set| %> + + + + + + <% end %> + + +
+ + + + <%= @image_sets_count %> Image Sets + +
+ <% if current_user.admin? %> + + <% end %> +
+
+
+ Download: +
+ + + +
+
+ + + + <%= + image_tag image_set.variant_url(ImageVariantable::SIZES[:list]), + class: 'rounded img-stack', + style: "max-width: 150px;max-height: 150px;" + %> + + +
+
+
+
+ Name +
+
+ <%= link_to image_set.name, {controller: 'image_sets', action: 'show', id: image_set.id} %> +
+
+ # Images +
+
+ <%= image_set.image_count %> +
+
+ Folder +
+ +
+ Created +
+
+ <%= image_set.created_at %> +
+
+
+
+
+
+ Grading Sets +
+
+ +
+
+
+
+
+
+ + +<% end %> + +<%= javascript_pack_tag 'image_scripts' %> \ No newline at end of file diff --git a/app/views/image_sets/show.html.erb b/app/views/image_sets/show.html.erb new file mode 100644 index 0000000..ff806c1 --- /dev/null +++ b/app/views/image_sets/show.html.erb @@ -0,0 +1,124 @@ +<% + label_class = "col-lg-3 col-md-4 fw-bold text-md-end mb-1" + data_class = "col-lg-9 col-md-8 mb-1" +%> + + + +
+ +
+ + <% @image_set.images.each do |image| %> + + + + + <% end %> +
+ + <%= + image_tag image.variant_url(ImageVariantable::SIZES[:list]), + style: "max-width: 150px;max-height: 150px;" + %> + + +
+
+
+
+ Filename +
+
+ <%= link_to image.filename, {controller: 'images', action: 'show', id: image.id} %> +
+
+ Uploaded +
+
+ <%= image.created_at %> +
+
+
+
+
+
+ +
+

Image Set Details

+ +
+
+ Image Folder +
+ +
+ Name +
+
+ <%= @image_set.name %> +
+
+ Metadata Value +
+
+ <%= @image_set.source_metadata_name %> +
+ +
+ Grading Sets +
+
+ +
+
+ + <% if current_user.admin? %> +

+ Grading Data +

+ download + + <% end %> + +

+ Metadata + +

+ + + <% @image_set.metadata.keys.each do |key| %> + + + + + <% end %> + +
<%= key %><%= @image_set.metadata[key] %>
+
+ +
\ No newline at end of file diff --git a/app/views/image_sources/_form.html.erb b/app/views/image_sources/_form.html.erb index 709ce37..ecdd257 100644 --- a/app/views/image_sources/_form.html.erb +++ b/app/views/image_sources/_form.html.erb @@ -3,6 +3,20 @@ <%= label_tag :name, "Image Folder Name", for: 'name', class: 'form-label' %> <%= form.text_field :name, class: 'form-control' %> +
+ <%= form.check_box :create_image_sets, class: 'form-check-input' %> + <%= label_tag :name, "Sort images into image sets?", for: 'active', class: 'form-check-label' %> +
+ When checked, images uploaded to this folder will be sorted automatically into image sets based on a metadata field's value. +
+ The name of the set will be the value of the metadata field. +
+ <%= label_tag :create_image_sets_metadata_field, "Image set metadata field", for: 'name', class: 'form-label' %> + <%= form.text_field :create_image_sets_metadata_field, class: 'form-control' %> +
+
+
+
<%= form.check_box :active, class: 'form-check-input' %> <%= label_tag :name, "Active?", for: 'active', class: 'form-check-label' %> diff --git a/app/views/image_sources/index.html.erb b/app/views/image_sources/index.html.erb index 4f84352..192cdc0 100644 --- a/app/views/image_sources/index.html.erb +++ b/app/views/image_sources/index.html.erb @@ -4,6 +4,8 @@ Name # of Images + Image Sets? + Image Set Metadata Active? @@ -11,8 +13,14 @@ <% @image_sources.each do |source| %> - <%= source.name %> - <%= source.image_count %> + + <%= link_to source.name, controller: 'image_sources', action: 'show', id: source.id %> + + + <%= link_to source.image_count, controller: 'images', image_source_id: source.id %> + + <%= source.create_image_sets? ? 'Yes' : '' %> + <%= source.create_image_sets_metadata_field %> <%= source.active? ? 'Active' : 'Inactive' %> <%= link_to 'upload images', controller: 'images', action: 'new', image_source_id: source.id %> diff --git a/app/views/images/index.html.erb b/app/views/images/index.html.erb index 3240809..fda1e42 100644 --- a/app/views/images/index.html.erb +++ b/app/views/images/index.html.erb @@ -19,13 +19,6 @@ %> -
- - -
+
+ + +
<% end %> - - - - - - - - - <% @images.each do |image| %> +
+
- - - - <%= @images_count %> Images - -
- <% if current_user.admin? %> - - <% end %> - - - -
-
+ - - + + + + <% @images.each do |image| %> + + + + - - <% end %> - -
+ - - - - <%= - image_tag image.variant_url(Image::SIZES[:list]), - class: "img-thumbnail", - style: "max-width: 150px;max-height: 150px;" - %> - - -
-
-
-
- Filename -
-
- <%= link_to image.filename, {controller: 'images', action: 'show', id: image.id} %> -
-
- Folder -
- -
- Uploaded On -
-
- <%= image.created_at %> -
-
+ +
+ + <%= @images_count %> Images + +
+ <% if current_user.admin? %> + + <% end %> +
+
+
+ Download:
-
-
-
- Grading Sets + + + + + +
+
+ + + + <%= + image_tag image.variant_url(ImageVariantable::SIZES[:list]), + class: 'rounded', + style: "max-width: 150px;max-height: 150px;" + %> + + +
+
+
+
+ Filename +
+
+ <%= link_to image.filename, {controller: 'images', action: 'show', id: image.id} %> +
+
+ Folder +
+ +
+ Uploaded On +
+
+ <%= image.created_at %> +
-
-
    - <% image.grading_sets.each do |grading_set| %> -
  • - <%= grading_set_link grading_set %> -
  • - <% end %> -
+
+
+
+
+ Grading Sets +
+
+
    + <% image.grading_sets.each do |grading_set| %> +
  • + <%= grading_set_link grading_set %> +
  • + <% end %> +
+
-
+
- -
+ + + <% end %> + + +