diff --git a/Gemfile b/Gemfile index 445748651..f769638b7 100644 --- a/Gemfile +++ b/Gemfile @@ -37,6 +37,7 @@ gem 'jsonapi-renderer', '~> 0.2.2' gem 'jwt', '~> 1.5' gem 'kaminari' gem 'rack-attack' +gem 'rack-cors' # JS datetime library, requirement of datetime picker gem 'momentjs-rails', '~> 2.17.1' diff --git a/Gemfile.lock b/Gemfile.lock index 0b815e936..c7dc1f057 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -410,6 +410,8 @@ GEM rack (2.2.3) rack-attack (6.1.0) rack (>= 1.0, < 3) + rack-cors (1.1.1) + rack (>= 2.0.0) rack-proxy (0.6.5) rack rack-test (1.1.0) @@ -668,6 +670,7 @@ DEPENDENCIES pry-rails puma rack-attack + rack-cors rails (~> 6.0.0) rails-controller-testing rails_12factor diff --git a/VERSION b/VERSION index 0044d6cb9..769e37e15 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.20.1 +1.20.2 diff --git a/app/assets/javascripts/my_modules/pwa_mobile_app.js b/app/assets/javascripts/my_modules/pwa_mobile_app.js new file mode 100644 index 000000000..e9f4755ea --- /dev/null +++ b/app/assets/javascripts/my_modules/pwa_mobile_app.js @@ -0,0 +1,6 @@ +(function() { + // Show button only on mobile devices + if ('ontouchstart' in window) { + $('.open-mobile-app-container').show(); + } +}()); diff --git a/app/assets/javascripts/my_modules/repositories.js b/app/assets/javascripts/my_modules/repositories.js index 98828ba70..897553a4d 100644 --- a/app/assets/javascripts/my_modules/repositories.js +++ b/app/assets/javascripts/my_modules/repositories.js @@ -453,7 +453,8 @@ var MyModuleRepositories = (function() { FULL_VIEW_MODAL.on('show.bs.modal', function() { FULL_VIEW_MODAL.find('.table-container').empty(); - FULL_VIEW_MODAL.find('.repository-name').empty(); + FULL_VIEW_MODAL.find('.repository-title').empty(); + FULL_VIEW_MODAL.find('.repository-version').empty(); updateFullViewRowsCount(''); }); } @@ -518,29 +519,31 @@ var MyModuleRepositories = (function() { function updateFullViewRowsCount(value) { FULL_VIEW_MODAL.data('rows-count', value); - FULL_VIEW_MODAL.find('.repository-name').attr('data-rows-count', value); + FULL_VIEW_MODAL.find('.repository-version').attr('data-rows-count', value); } function renderFullViewRepositoryName(name, snapshotDate, assignMode) { var title; - var repositoryName = name || FULL_VIEW_MODAL.find('.repository-name').data('repository-name'); + var version; + var repositoryName = name || FULL_VIEW_MODAL.find('.repository-title').data('repository-name'); if (assignMode) { title = I18n.t('my_modules.repository.full_view.assign_modal_header', { repository_name: repositoryName }); + version = ''; } else if (snapshotDate) { - title = I18n.t('my_modules.repository.full_view.modal_snapshot_header', { - repository_name: repositoryName, + title = repositoryName; + version = I18n.t('my_modules.repository.full_view.modal_snapshot_header', { snaphot_date: snapshotDate }); } else { - title = I18n.t('my_modules.repository.full_view.modal_live_header', { - repository_name: repositoryName - }); + title = repositoryName; + version = I18n.t('my_modules.repository.full_view.modal_live_header'); } - FULL_VIEW_MODAL.find('.repository-name').data('repository-name', repositoryName); - FULL_VIEW_MODAL.find('.repository-name').html(title); + FULL_VIEW_MODAL.find('.repository-title').data('repository-name', repositoryName); + FULL_VIEW_MODAL.find('.repository-title').html(title); + FULL_VIEW_MODAL.find('.repository-version').html(version); } function initRepoistoryAssignView() { diff --git a/app/assets/stylesheets/my_modules/repositories.scss b/app/assets/stylesheets/my_modules/repositories.scss index 5e218f422..93d6d0dc5 100644 --- a/app/assets/stylesheets/my_modules/repositories.scss +++ b/app/assets/stylesheets/my_modules/repositories.scss @@ -5,21 +5,9 @@ @include font-h3; line-height: 22px; overflow: hidden; - padding-right: 55px; position: relative; text-overflow: ellipsis; white-space: nowrap; - - &::after { - color: $color-alto; - content: '[' attr(data-rows-count) ']'; - display: inline-block; - line-height: 22px; - padding-left: 5px; - position: absolute; - right: 0; - width: 55px; - } } .my-module-inventories { @@ -131,6 +119,16 @@ .assigned-repository-title { @include my-module-repository-title; + padding-right: 2.2em; + + &::after { + color: $color-alto; + content: '[' attr(data-rows-count) ']'; + display: inline-block; + padding-right: .7em; + position: absolute; + right: 0; + } } .action-buttons { @@ -218,11 +216,26 @@ flex-grow: 1; max-width: calc(100% - 20px); - .repository-name { + .repository-name-container { + display: flex; + } + + .repository-title { @include my-module-repository-title; @include font-h2; - display: inline-block; - width: 100%; + } + + .repository-version { + @include font-h2; + flex-shrink: 0; + padding-right: .7em; + + &::after { + color: $color-alto; + content: '[' attr(data-rows-count) ']'; + display: inline-block; + padding-left: .3em; + } } .breadcrumbs { diff --git a/app/assets/stylesheets/shared_styles/elements/buttons.scss b/app/assets/stylesheets/shared_styles/elements/buttons.scss index 18f996c98..51364b734 100644 --- a/app/assets/stylesheets/shared_styles/elements/buttons.scss +++ b/app/assets/stylesheets/shared_styles/elements/buttons.scss @@ -106,6 +106,30 @@ } } + &.btn-light-link { + background: transparent; + border: $border-transparent; + color: $brand-primary; + + &:hover { + background: $color-concrete; + border: $border-transparent; + color: $brand-primary; + } + + &:active, + &.active { + background: $color-alto; + border: $border-transparent; + color: $brand-primary; + } + + &:focus { + box-shadow: 0 0 0 1px $brand-focus; + color: $brand-primary; + } + } + &.btn-danger { background: $brand-danger; border: $border-danger; @@ -128,6 +152,28 @@ } } + &.btn-dark-background { + background: $color-white; + border: $border-default; + color: $brand-primary; + + &:hover { + background: $color-concrete; + color: $brand-primary-hover; + } + + &:active, + &.active { + background: $color-alto; + color: $brand-primary-press; + } + + &:focus { + box-shadow: 0 0 0 1px $brand-focus; + color: $brand-primary; + } + } + &.icon-btn { padding: 7px; width: 36px; @@ -137,6 +183,12 @@ } } + &.btn-large { + font-size: $font-size-h2; + height: 3.1em; + padding: 1em 3em; + } + &.disabled, &:disabled { color: $color-silver-chalice; @@ -155,7 +207,9 @@ } &.btn-secondary, - &.btn-light { + &.btn-light, + &.btn-light-link, + &.btn-dark-background { background: $color-white; &:hover { diff --git a/app/assets/stylesheets/themes/main_navigation.scss b/app/assets/stylesheets/themes/main_navigation.scss index f3c406d97..4c0843fe8 100644 --- a/app/assets/stylesheets/themes/main_navigation.scss +++ b/app/assets/stylesheets/themes/main_navigation.scss @@ -46,13 +46,21 @@ .navbar-default .navbar-brand { align-items: center; display: flex; - padding: 0 15px; + padding: 0 .3em 0 .8em; #logo { max-height: 22px; } } +.open-mobile-app-container { + display: none; +} + +.open-mobile-app-button { + margin-top: 8px; +} + .dropdown-notifications { max-height: 500px; overflow-x: hidden; @@ -306,7 +314,7 @@ text-align: right; text-overflow: ellipsis; white-space: nowrap; - width: 250px; + width: 230px; } .btn-group { diff --git a/app/assets/stylesheets/themes/scinote.scss b/app/assets/stylesheets/themes/scinote.scss index 10e4e5f65..dfb8b655b 100644 --- a/app/assets/stylesheets/themes/scinote.scss +++ b/app/assets/stylesheets/themes/scinote.scss @@ -1784,7 +1784,7 @@ a.disabled-with-click-events { margin-bottom: 45px; .doorkeeper-scinote-logo { - background-image: asset-url("/images/scinote_icon.jpg"); + background-image: asset-url("/images/doorkeeper_auth.png"); background-position: center center; background-repeat: no-repeat; background-size: contain; diff --git a/app/controllers/active_storage/custom_base_controller.rb b/app/controllers/active_storage/custom_base_controller.rb index d57e12337..6092420b8 100644 --- a/app/controllers/active_storage/custom_base_controller.rb +++ b/app/controllers/active_storage/custom_base_controller.rb @@ -3,6 +3,10 @@ # The base controller for all ActiveStorage controllers. module ActiveStorage class CustomBaseController < ApplicationController + include TokenAuthentication include ActiveStorage::SetCurrent + + prepend_before_action :authenticate_request!, if: -> { request.headers['Authorization'].present? } + skip_before_action :authenticate_user!, if: -> { current_user.present? } end end diff --git a/app/controllers/api/api_controller.rb b/app/controllers/api/api_controller.rb index a7e952991..29b99f986 100644 --- a/app/controllers/api/api_controller.rb +++ b/app/controllers/api/api_controller.rb @@ -2,8 +2,8 @@ module Api class ApiController < ActionController::API - attr_reader :iss - attr_reader :token + include TokenAuthentication + attr_reader :current_user before_action :authenticate_request!, except: %i(status health) @@ -53,45 +53,5 @@ module Api end render json: response, status: :ok end - - private - - def azure_jwt_auth - return unless iss =~ %r{windows.net/|microsoftonline.com/} - token_payload, = Api::AzureJwt.decode(token) - @current_user = User.from_azure_jwt_token(token_payload) - unless current_user - raise JWT::InvalidPayload, I18n.t('api.core.no_azure_user_mapping') - end - end - - def authenticate_request! - @token = request.headers['Authorization']&.sub('Bearer ', '') - unless @token - raise JWT::VerificationError, I18n.t('api.core.missing_token') - end - - @iss = CoreJwt.read_iss(token) - raise JWT::InvalidPayload, I18n.t('api.core.no_iss') unless @iss - - Extends::API_PLUGABLE_AUTH_METHODS.each do |auth_method| - method(auth_method).call - return true if current_user - end - - # Default token implementation - unless iss == Rails.configuration.x.core_api_token_iss - raise JWT::InvalidPayload, I18n.t('api.core.wrong_iss') - end - payload = CoreJwt.decode(token) - @current_user = User.find_by_id(payload['sub']) - unless current_user - raise JWT::InvalidPayload, I18n.t('api.core.no_user_mapping') - end - end - - def auth_params - params.permit(:grant_type, :email, :password) - end end end diff --git a/app/controllers/api/v1/checklist_items_controller.rb b/app/controllers/api/v1/checklist_items_controller.rb index d8f1c6c5f..f51284962 100644 --- a/app/controllers/api/v1/checklist_items_controller.rb +++ b/app/controllers/api/v1/checklist_items_controller.rb @@ -3,6 +3,8 @@ module Api module V1 class ChecklistItemsController < BaseController + include ApplicationHelper + before_action :load_team, :load_project, :load_experiment, :load_task, :load_protocol, :load_step, :load_checklist before_action only: :show do load_checklist_item(:id) @@ -31,6 +33,23 @@ module Api @checklist_item.assign_attributes(checklist_item_params) if @checklist_item.changed? && @checklist_item.save! + if @checklist_item.saved_change_to_attribute?(:checked) + completed_items = @checklist_item.checklist.checklist_items.where(checked: true).count + all_items = @checklist_item.checklist.checklist_items.count + text_activity = smart_annotation_parser(@checklist_item.text).gsub(/\s+/, ' ') + type_of = if @checklist_item.saved_change_to_attribute(:checked).last + :check_step_checklist_item + else + :uncheck_step_checklist_item + end + log_activity(type_of, + my_module: @task.id, + step: @step.id, + step_position: { id: @step.id, value_for: 'position_plus_one' }, + checkbox: text_activity, + num_completed: completed_items.to_s, + num_all: all_items.to_s) + end render jsonapi: @checklist_item, serializer: ChecklistItemSerializer, status: :ok else render body: nil, status: :no_content @@ -54,6 +73,18 @@ module Api @checklist_item = @checklist.checklist_items.find(params.require(:id)) raise PermissionError.new(Protocol, :manage) unless can_manage_protocol_in_module?(@protocol) end + + def log_activity(type_of, message_items = {}) + default_items = { step: @step.id, step_position: { id: @step.id, value_for: 'position_plus_one' } } + message_items = default_items.merge(message_items) + + Activities::CreateActivityService.call(activity_type: type_of, + owner: current_user, + subject: @protocol, + team: @team, + project: @project, + message_items: message_items) + end end end end diff --git a/app/controllers/api/v1/steps_controller.rb b/app/controllers/api/v1/steps_controller.rb index c6e8bafe8..10f3c5897 100644 --- a/app/controllers/api/v1/steps_controller.rb +++ b/app/controllers/api/v1/steps_controller.rb @@ -41,6 +41,14 @@ module Api @step.assign_attributes(step_params) if @step.changed? && @step.save! + if @step.saved_change_to_attribute?(:completed) + completed_steps = @protocol.steps.where(completed: true).count + all_steps = @protocol.steps.count + type_of = @step.saved_change_to_attribute(:completed).last ? :complete_step : :uncomplete_step + log_activity(type_of, my_module: @task.id, + num_completed: completed_steps.to_s, + num_all: all_steps.to_s) + end render jsonapi: @step, serializer: StepSerializer, status: :ok else render body: nil, status: :no_content @@ -68,6 +76,18 @@ module Api @step = @protocol.steps.find(params.require(:id)) raise PermissionError.new(Protocol, :manage) unless can_manage_protocol_in_module?(@step.protocol) end + + def log_activity(type_of, message_items = {}) + default_items = { step: @step.id, step_position: { id: @step.id, value_for: 'position_plus_one' } } + message_items = default_items.merge(message_items) + + Activities::CreateActivityService.call(activity_type: type_of, + owner: current_user, + subject: @protocol, + team: @team, + project: @project, + message_items: message_items) + end end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index db89d7e6f..e09bc460d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,5 @@ class ApplicationController < ActionController::Base - acts_as_token_authentication_handler_for User + acts_as_token_authentication_handler_for User, unless: -> { current_user.present? } # Prevent CSRF attacks by raising an exception. # For APIs, you may want to use :null_session instead. protect_from_forgery with: :exception, prepend: true diff --git a/app/controllers/concerns/token_authentication.rb b/app/controllers/concerns/token_authentication.rb new file mode 100644 index 000000000..a6ed6d2cd --- /dev/null +++ b/app/controllers/concerns/token_authentication.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module TokenAuthentication + extend ActiveSupport::Concern + + private + + def azure_jwt_auth + return unless @token_iss.match?(%r{windows.net/|microsoftonline.com/}) + + token_payload, = Api::AzureJwt.decode(@token) + @current_user = User.from_azure_jwt_token(token_payload) + raise JWT::InvalidPayload, I18n.t('api.core.no_azure_user_mapping') unless current_user + end + + def authenticate_request! + @token = request.headers['Authorization']&.sub('Bearer ', '') + raise JWT::VerificationError, I18n.t('api.core.missing_token') unless @token + + @token_iss = Api::CoreJwt.read_iss(@token) + raise JWT::InvalidPayload, I18n.t('api.core.no_iss') unless @token_iss + + Extends::API_PLUGABLE_AUTH_METHODS.each do |auth_method| + method(auth_method).call + return true if current_user + end + + # Default token implementation + unless @token_iss == Rails.configuration.x.core_api_token_iss + raise JWT::InvalidPayload, I18n.t('api.core.wrong_iss') + end + + payload = Api::CoreJwt.decode(@token) + @current_user = User.find_by(id: payload['sub']) + raise JWT::InvalidPayload, I18n.t('api.core.no_user_mapping') unless current_user + end +end diff --git a/app/helpers/pwa_helper.rb b/app/helpers/pwa_helper.rb new file mode 100644 index 000000000..4c8e50fc9 --- /dev/null +++ b/app/helpers/pwa_helper.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module PwaHelper + def pwa_mobile_app_url(team_id, project_id, experiment_id, task_id, protocol_id, step_id, domain) + url = Constants::PWA_URL.dup + { + ':pwa_domain' => Rails.configuration.x.pwa_domain, + ':team_id' => team_id, + ':project_id' => project_id, + ':experiment_id' => experiment_id, + ':task_id' => task_id, + ':protocol_id' => protocol_id, + ':step_id' => step_id, + ':domain' => domain + }.each { |k, v| url.gsub!(k, v.to_s) } + url + end +end diff --git a/app/models/my_module_status_consequences/repository_snapshot.rb b/app/models/my_module_status_consequences/repository_snapshot.rb index 090700f13..bd8d8baf9 100644 --- a/app/models/my_module_status_consequences/repository_snapshot.rb +++ b/app/models/my_module_status_consequences/repository_snapshot.rb @@ -10,10 +10,16 @@ module MyModuleStatusConsequences my_module.assigned_repositories.each do |repository| repository_snapshot = ::RepositorySnapshot.create_preliminary(repository, my_module) service = Repositories::SnapshotProvisioningService.call(repository_snapshot: repository_snapshot) + unless service.succeed? repository_snapshot.failed! raise StandardError, service.errors end + + snapshot = service.repository_snapshot + unless snapshot.my_module.repository_snapshots.where(parent_id: snapshot.parent_id).find_by(selected: true) + snapshot.update!(selected: true) + end end end end diff --git a/app/models/protocol.rb b/app/models/protocol.rb index 4ddafea6a..dd0237e9f 100644 --- a/app/models/protocol.rb +++ b/app/models/protocol.rb @@ -364,6 +364,10 @@ class Protocol < ApplicationRecord steps.where(completed: true) end + def first_step_id + steps.find_by(position: 0)&.id + end + def space_taken st = 0 steps.find_each do |step| diff --git a/app/models/repository_checklist_value.rb b/app/models/repository_checklist_value.rb index cd09c9f1a..d3ab9e1cf 100644 --- a/app/models/repository_checklist_value.rb +++ b/app/models/repository_checklist_value.rb @@ -75,6 +75,8 @@ class RepositoryChecklistValue < ApplicationRecord end def self.import_from_text(text, attributes, _options = {}) + return nil if text.blank? + value = new(attributes) column = attributes.dig(:repository_cell_attributes, :repository_column) RepositoryImportParser::Util.split_by_delimiter(text: text, delimiter: column.delimiter_char).each do |item_text| diff --git a/app/models/repository_list_value.rb b/app/models/repository_list_value.rb index b99b08345..44392bb32 100644 --- a/app/models/repository_list_value.rb +++ b/app/models/repository_list_value.rb @@ -66,6 +66,8 @@ class RepositoryListValue < ApplicationRecord end def self.import_from_text(text, attributes, _options = {}) + return nil if text.blank? + value = new(attributes) column = attributes.dig(:repository_cell_attributes, :repository_column) list_item = column.repository_list_items.find { |item| item.data == text } diff --git a/app/services/reports/docx.rb b/app/services/reports/docx.rb index 4fd93088f..75686b989 100644 --- a/app/services/reports/docx.rb +++ b/app/services/reports/docx.rb @@ -34,53 +34,5 @@ class Reports::Docx end @docx end - - def self.link_prepare(scinote_url, link) - link[0] == '/' ? scinote_url + link : link - end - - def self.render_p_element(docx, element, options = {}) - scinote_url = options[:scinote_url] - link_style = options[:link_style] - docx.p do - element[:children].each do |text_el| - if text_el[:type] == 'text' - style = text_el[:style] || {} - text text_el[:value], style - text ' ' if text_el[:value] != '' - elsif text_el[:type] == 'br' && !options[:skip_br] - br - elsif text_el[:type] == 'a' - if text_el[:link] - link_url = Reports::Docx.link_prepare(scinote_url, text_el[:link]) - link text_el[:value], link_url, link_style - else - text text_el[:value], link_style - end - text ' ' if text_el[:value] != '' - end - end - end - end - - def self.render_img_element(docx, element, options = {}) - style = element[:style] - - if options[:table] - max_width = (style[:max_width] / options[:table][:columns].to_f) - if style[:width] > max_width - style[:height] = (max_width / style[:width].to_f) * style[:height] - style[:width] = max_width - end - end - - docx.img element[:data] do - data element[:blob].download - width style[:width] - height style[:height] - align style[:align] || :left - end - end end - # rubocop:enable Style/ClassAndModuleChildren diff --git a/app/services/reports/docx/draw_experiment.rb b/app/services/reports/docx/draw_experiment.rb index e61595846..b1aa74350 100644 --- a/app/services/reports/docx/draw_experiment.rb +++ b/app/services/reports/docx/draw_experiment.rb @@ -22,7 +22,8 @@ module Reports::Docx::DrawExperiment link_style end html = custom_auto_link(experiment.description, team: @report_team) - html_to_word_converter(html) + Reports::HtmlToWordConverter.new(@docx, { scinote_url: scinote_url, + link_style: link_style }).html_to_word_converter(html) @docx.p subject['children'].each do |child| public_send("draw_#{child['type_of']}", child, experiment) diff --git a/app/services/reports/docx/draw_my_module.rb b/app/services/reports/docx/draw_my_module.rb index 1f38b7435..c18b2896b 100644 --- a/app/services/reports/docx/draw_my_module.rb +++ b/app/services/reports/docx/draw_my_module.rb @@ -66,7 +66,8 @@ module Reports::Docx::DrawMyModule if my_module.description.present? html = custom_auto_link(my_module.description, team: @report_team) - html_to_word_converter(html) + Reports::HtmlToWordConverter.new(@docx, { scinote_url: scinote_url, + link_style: link_style }).html_to_word_converter(html) else @docx.p I18n.t('projects.reports.elements.module.no_description') end diff --git a/app/services/reports/docx/draw_my_module_activity.rb b/app/services/reports/docx/draw_my_module_activity.rb index fcf753a68..8747e5937 100644 --- a/app/services/reports/docx/draw_my_module_activity.rb +++ b/app/services/reports/docx/draw_my_module_activity.rb @@ -20,7 +20,7 @@ module Reports::Docx::DrawMyModuleActivity sanitize_input(generate_activity_content(activity, true)) end @docx.p I18n.l(activity_ts, format: :full), color: color[:gray] - html_to_word_converter(activity_text) + Reports::HtmlToWordConverter.new(@docx).html_to_word_converter(activity_text) @docx.p end end diff --git a/app/services/reports/docx/draw_my_module_protocol.rb b/app/services/reports/docx/draw_my_module_protocol.rb index 97d04fc07..881c79aa1 100644 --- a/app/services/reports/docx/draw_my_module_protocol.rb +++ b/app/services/reports/docx/draw_my_module_protocol.rb @@ -11,7 +11,7 @@ module Reports::Docx::DrawMyModuleProtocol timestamp: I18n.l(protocol.created_at, format: :full) @docx.hr html = custom_auto_link(protocol.description, team: @report_team) - html_to_word_converter(html) + Reports::HtmlToWordConverter.new(@docx).html_to_word_converter(html) @docx.p @docx.p end diff --git a/app/services/reports/docx/draw_result_asset.rb b/app/services/reports/docx/draw_result_asset.rb index b1fda37fa..fb5ffa88d 100644 --- a/app/services/reports/docx/draw_result_asset.rb +++ b/app/services/reports/docx/draw_result_asset.rb @@ -17,7 +17,7 @@ module Reports::Docx::DrawResultAsset user: result.user.full_name, timestamp: I18n.l(timestamp, format: :full)), color: color[:gray] end - asset_image_preparing(asset) if asset.image? + Reports::DocxRenderer.render_asset_image(@docx, asset) if asset.image? subject['children'].each do |child| public_send("draw_#{child['type_of']}", child, result) diff --git a/app/services/reports/docx/draw_result_comments.rb b/app/services/reports/docx/draw_result_comments.rb index b738808d8..101290b24 100644 --- a/app/services/reports/docx/draw_result_comments.rb +++ b/app/services/reports/docx/draw_result_comments.rb @@ -17,7 +17,8 @@ module Reports::Docx::DrawResultComments date: I18n.l(comment_ts, format: :full_date), time: I18n.l(comment_ts, format: :time)), italic: true html = custom_auto_link(comment.message, team: @report_team) - html_to_word_converter(html) + Reports::HtmlToWordConverter.new(@docx, { scinote_url: @scinote_url, + link_style: @link_style }).html_to_word_converter(html) @docx.p end end diff --git a/app/services/reports/docx/draw_result_text.rb b/app/services/reports/docx/draw_result_text.rb index ee87953e0..33abb9867 100644 --- a/app/services/reports/docx/draw_result_text.rb +++ b/app/services/reports/docx/draw_result_text.rb @@ -17,7 +17,8 @@ module Reports::Docx::DrawResultText timestamp: I18n.l(timestamp, format: :full), user: result.user.full_name), color: color[:gray] end html = custom_auto_link(result_text.text, team: @report_team) - html_to_word_converter(html) + Reports::HtmlToWordConverter.new(@docx, { scinote_url: @scinote_url, + link_style: @link_style }).html_to_word_converter(html) subject['children'].each do |child| public_send("draw_#{child['type_of']}", child, result) diff --git a/app/services/reports/docx/draw_step.rb b/app/services/reports/docx/draw_step.rb index 064d3a85a..87ac65c7a 100644 --- a/app/services/reports/docx/draw_step.rb +++ b/app/services/reports/docx/draw_step.rb @@ -27,7 +27,8 @@ module Reports::Docx::DrawStep end if step.description.present? html = custom_auto_link(step.description, team: @report_team) - html_to_word_converter(html) + Reports::HtmlToWordConverter.new(@docx, { scinote_url: @scinote_url, + link_style: @link_style }).html_to_word_converter(html) else @docx.p I18n.t 'projects.reports.elements.step.no_description' end diff --git a/app/services/reports/docx/draw_step_asset.rb b/app/services/reports/docx/draw_step_asset.rb index e8560ddf0..80a87c8da 100644 --- a/app/services/reports/docx/draw_step_asset.rb +++ b/app/services/reports/docx/draw_step_asset.rb @@ -15,6 +15,6 @@ module Reports::Docx::DrawStepAsset timestamp: I18n.l(timestamp, format: :full)), color: color[:gray] end - asset_image_preparing(asset) if asset.image? + Reports::DocxRenderer.render_asset_image(@docx, asset) if asset.image? end end diff --git a/app/services/reports/docx/draw_step_comments.rb b/app/services/reports/docx/draw_step_comments.rb index 5e04956ec..40bee154d 100644 --- a/app/services/reports/docx/draw_step_comments.rb +++ b/app/services/reports/docx/draw_step_comments.rb @@ -17,7 +17,8 @@ module Reports::Docx::DrawStepComments date: I18n.l(comment_ts, format: :full_date), time: I18n.l(comment_ts, format: :time)), italic: true html = custom_auto_link(comment.message, team: @report_team) - html_to_word_converter(html) + Reports::HtmlToWordConverter.new(@docx, { scinote_url: @scinote_url, + link_style: @link_style }).html_to_word_converter(html) @docx.p end end diff --git a/app/services/reports/docx/private_methods.rb b/app/services/reports/docx/private_methods.rb index 257d71e4b..3a17a5d77 100644 --- a/app/services/reports/docx/private_methods.rb +++ b/app/services/reports/docx/private_methods.rb @@ -3,222 +3,6 @@ module Reports::Docx::PrivateMethods private - # RTE fields support - def html_to_word_converter(text) - html = Nokogiri::HTML(text) - raw_elements = recursive_children(html.css('body').children, []) - - # Combined raw text blocks in paragraphs - elements = combine_docx_elements(raw_elements) - - # Draw elements - elements.each do |elem| - if elem[:type] == 'p' - Reports::Docx.render_p_element(@docx, elem, scinote_url: @scinote_url, link_style: @link_style) - elsif elem[:type] == 'table' - tiny_mce_table(elem[:data]) - elsif elem[:type] == 'newline' - style = elem[:style] || {} - # print heading if its heading - # Mixing heading with other style setting causes problems for Word - if %w(h1 h2 h3 h4 h5).include?(style[:style]) - @docx.public_send(style[:style], elem[:value]) - else - @docx.p elem[:value] do - align style[:align] - color style[:color] - bold style[:bold] - italic style[:italic] - end - end - elsif elem[:type] == 'image' - Reports::Docx.render_img_element(@docx, elem) - end - end - end - - def combine_docx_elements(raw_elements) - elements = [] - temp_p = [] - raw_elements.each do |elem| - if %w(image newline table).include? elem[:type] - unless temp_p.empty? - elements.push(type: 'p', children: temp_p) - temp_p = [] - end - elements.push(elem) - elsif %w(br text a).include? elem[:type] - temp_p.push(elem) - end - end - elements.push(type: 'p', children: temp_p) - elements - end - - # Convert HTML structure to plain text structure - def recursive_children(children, elements, options = {}) - children.each do |elem| - if elem.class == Nokogiri::XML::Text - next if elem.text.strip == ' ' # Invisible symbol - - style = paragraph_styling(elem.parent) - type = (style[:align] && style[:align] != :justify) || style[:style] ? 'newline' : 'text' - - text = smart_annotation_check(elem) - - elements.push( - type: type, - value: text.strip.delete(' '), # Invisible symbol - style: style - ) - next - end - - if elem.name == 'br' - elements.push(type: 'br') - next - end - - if elem.name == 'img' && elem.attributes['data-mce-token'] - - image = TinyMceAsset.find_by(id: Base62.decode(elem.attributes['data-mce-token'].value)) - next unless image - - image_path = image_path(image.image) - dimension = FastImage.size(image_path) - - next unless dimension - - style = image_styling(elem, dimension) - - elements.push( - type: 'image', - data: image_path.split('&')[0], - blob: image.blob, - style: style - ) - next - end - - if elem.name == 'a' - elements.push(link_element(elem)) - next - end - - if elem.name == 'table' - elem = tiny_mce_table(elem, nested_table: true) if options[:nested_tables] - elements.push( - type: 'table', - data: elem - ) - next - end - - elements = recursive_children(elem.children, elements) if elem.children - end - elements - end - - def link_element(elem) - text = elem.text - link = elem.attributes['href'].value if elem.attributes['href'] - if elem.attributes['class']&.value == 'record-info-link' - link = nil - text = "##{text}" - end - text = "##{text}" if elem.parent.attributes['class']&.value == 'atwho-inserted' - text = "@#{text}" if elem.attributes['class']&.value == 'atwho-user-popover' - { - type: 'a', - value: text, - link: link - } - end - - def smart_annotation_check(elem) - return "[#{elem.text}]" if elem.parent.attributes['class']&.value == 'sa-type' - - elem.text - end - - # Prepare style for text - def paragraph_styling(elem) - style = elem.attributes['style'] - result = {} - result[:style] = elem.name if elem.name.include? 'h' - result[:bold] = true if elem.name == 'strong' - result[:italic] = true if elem.name == 'em' - style_keys = %w(text-align color) - - if style - style_keys.each do |key| - style_el = style.value.split(';').select { |i| (i.include? key) }[0] - next unless style_el - - value = style_el.split(':')[1].strip if style_el - if key == 'text-align' - result[:align] = value.to_sym - elsif key == 'color' && calculate_color_hsp(value) < 190 - result[:color] = value.delete('#') - end - end - end - result - end - - # Prepare style for images - def image_styling(elem, dimension) - dimension[0] = elem.attributes['width'].value.to_i if elem.attributes['width'] - dimension[1] = elem.attributes['height'].value.to_i if elem.attributes['height'] - - if elem.attributes['style'] - align = if elem.attributes['style'].value.include? 'margin-right' - :center - elsif elem.attributes['style'].value.include? 'float: right' - :right - else - :left - end - end - - margins = Constants::REPORT_DOCX_MARGIN_LEFT + Constants::REPORT_DOCX_MARGIN_RIGHT - max_width = (Constants::REPORT_DOCX_WIDTH - margins) / 20 - - if dimension[0] > max_width - x = max_width - y = dimension[1] * max_width / dimension[0] - else - x = dimension[0] - y = dimension[1] - end - - { - width: x, - height: y, - align: align, - max_width: max_width - } - end - - def asset_image_preparing(asset) - return unless asset - - image_path = image_path(asset.file) - - dimension = FastImage.size(image_path) - x = dimension[0] - y = dimension[1] - if x > 300 - y = y * 300 / x - x = 300 - end - @docx.img image_path.split('&')[0] do - data asset.blob.download - width x - height y - end - end - def initial_document_load @docx.page_size do width Constants::REPORT_DOCX_WIDTH @@ -269,60 +53,4 @@ module Reports::Docx::PrivateMethods green: '2dbe61' } end - - def tiny_mce_table(table_data, options = {}) - docx_table = [] - scinote_url = @scinote_url - link_style = @link_style - table_data.css('tbody').first.children.each do |row| - docx_row = [] - next unless row.name == 'tr' - - row.children.each do |cell| - next unless cell.name == 'td' - - # Parse cell content - formated_cell = recursive_children(cell.children, [], nested_tables: true) - # Combine text elements to single paragraph - formated_cell = combine_docx_elements(formated_cell) - - docx_cell = Caracal::Core::Models::TableCellModel.new do |c| - formated_cell.each do |cell_content| - if cell_content[:type] == 'p' - Reports::Docx.render_p_element(c, cell_content, - scinote_url: scinote_url, link_style: link_style, skip_br: true) - elsif cell_content[:type] == 'table' - c.table formated_cell_content[:data], border_size: Constants::REPORT_DOCX_TABLE_BORDER_SIZE - elsif cell_content[:type] == 'image' - Reports::Docx.render_img_element(c, cell_content, table: { columns: row.children.length / 3 }) - end - end - end - docx_row.push(docx_cell) - end - docx_table.push(docx_row) - end - - if options[:nested_table] - docx_table - else - @docx.table docx_table, border_size: Constants::REPORT_DOCX_TABLE_BORDER_SIZE - end - end - - def image_path(attachment) - attachment.service_url - end - - def calculate_color_hsp(color) - return 255 if color.length != 7 - - color = color.delete('#').scan(/.{1,2}/) - rgb = color.map(&:hex) - Math.sqrt( - 0.299 * (rgb[0]**2) + - 0.587 * (rgb[1]**2) + - 0.114 * (rgb[2]**2) - ) - end end diff --git a/app/services/reports/docx_renderer.rb b/app/services/reports/docx_renderer.rb new file mode 100644 index 000000000..7defe7b73 --- /dev/null +++ b/app/services/reports/docx_renderer.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +module Reports + class DocxRenderer + def self.render_p_element(docx, element, options = {}) + docx.p do + element[:children].each do |text_el| + if text_el[:type] == 'text' + style = text_el[:style] || {} + text text_el[:value], style + text ' ' if text_el[:value] != '' + elsif text_el[:type] == 'br' && !options[:skip_br] + br + elsif text_el[:type] == 'a' + Reports::DocxRenderer.render_link_element(self, text_el, options) + end + end + end + end + + def self.render_link_element(node, link_item, options = {}) + scinote_url = options[:scinote_url] + link_style = options[:link_style] + + if link_item[:link] + link_url = Reports::Utils.link_prepare(scinote_url, link_item[:link]) + node.link link_item[:value], link_url, link_style + else + node.text link_item[:value], link_style + end + node.text ' ' if link_item[:value] != '' + end + + def self.render_img_element(docx, element, options = {}) + style = element[:style] + + if options[:table] + max_width = (style[:max_width] / options[:table][:columns].to_f) + if style[:width] > max_width + style[:height] = (max_width / style[:width].to_f) * style[:height] + style[:width] = max_width + end + end + + docx.img element[:data] do + data element[:blob].download + width style[:width] + height style[:height] + align style[:align] || :left + end + end + + def self.render_list_element(docx, element, options = {}) + bookmark_items = Reports::DocxRenderer.recursive_list_items_renderer(docx, element) + + bookmark_items.each_with_index do |(key, item), index| + if item[:type] == 'image' + docx.bookmark_start id: index, name: key + docx.p do + br + text item[:blob]&.filename.to_s + end + Reports::DocxRenderer.render_img_element(docx, item) + docx.bookmark_end id: index + elsif item[:type] == 'table' + docx.bookmark_start id: index, name: key + + # Bookmark won't work with table only, empty p element added + docx.p do + br + text '' + end + Reports::DocxRenderer.render_table_element(docx, item, options) + docx.bookmark_end id: index + end + end + end + + # rubocop:disable Metrics/BlockLength + def self.recursive_list_items_renderer(node, element, bookmark_items: {}) + node.public_send(element[:type]) do + element[:data].each do |values_array| + li do + values_array.each do |item| + case item + when Hash + if %w(ul ol li).include?(item[:type]) + Reports::DocxRenderer.recursive_list_items_renderer(self, item, bookmark_items: bookmark_items) + elsif %w(a).include?(item[:type]) + Reports::DocxRenderer.render_link_element(self, item) + elsif %w(image).include?(item[:type]) + bookmark_items[item[:bookmark_id]] = item + link I18n.t('projects.reports.renderers.lists.appended_image', + name: item[:blob]&.filename), item[:bookmark_id] do + internal true + end + elsif %w(table).include?(item[:type]) + bookmark_items[item[:bookmark_id]] = item + link I18n.t('projects.reports.renderers.lists.appended_table'), item[:bookmark_id] do + internal true + end + elsif %w(text).include?(item[:type]) + # TODO: Text with styles, not working yet. + style = item[:style] || {} + text item[:value], style + end + else + text item + end + end + end + end + end + bookmark_items + end + # rubocop:enable Metrics/BlockLength + + def self.render_table_element(docx, element, options = {}) + docx_table = [] + element[:data].each do |row| + docx_row = [] + row[:data].each do |cell| + docx_cell = Caracal::Core::Models::TableCellModel.new do |c| + cell.each do |content| + if content[:type] == 'p' + Reports::DocxRenderer.render_p_element(c, content, options.merge({ skip_br: true })) + elsif content[:type] == 'table' + Reports::DocxRenderer.render_table_element(c, content, options) + elsif content[:type] == 'image' + Reports::DocxRenderer.render_img_element(c, content, table: { columns: row.children.length / 3 }) + end + end + end + docx_row.push(docx_cell) + end + docx_table.push(docx_row) + end + docx.table docx_table, border_size: Constants::REPORT_DOCX_TABLE_BORDER_SIZE + end + + def self.render_asset_image(docx, asset) + return unless asset + + image_path = Reports::Utils.image_path(asset.file) + + dimension = FastImage.size(image_path) + return unless dimension + + x = dimension[0] + y = dimension[1] + if x > 300 + y = y * 300 / x + x = 300 + end + docx.img image_path.split('&')[0] do + data asset.blob.download + width x + height y + end + end + end +end diff --git a/app/services/reports/html_to_word_converter.rb b/app/services/reports/html_to_word_converter.rb new file mode 100644 index 000000000..d04e7e1a1 --- /dev/null +++ b/app/services/reports/html_to_word_converter.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +module Reports + class HtmlToWordConverter + def initialize(document, options = {}) + @docx = document + @scinote_url = options[:scinote_url] + @link_style = options[:link_style] + end + + def html_to_word_converter(text) + html = Nokogiri::HTML(text) + raw_elements = recursive_children(html.css('body').children, []).compact + + # Combined raw text blocks in paragraphs + elements = combine_docx_elements(raw_elements) + + # Draw elements + elements.each do |elem| + if elem[:type] == 'p' + Reports::DocxRenderer.render_p_element(@docx, elem, scinote_url: @scinote_url, link_style: @link_style) + elsif elem[:type] == 'table' + Reports::DocxRenderer.render_table_element(@docx, elem) + elsif elem[:type] == 'newline' + style = elem[:style] || {} + # print heading if its heading + # Mixing heading with other style setting causes problems for Word + if %w(h1 h2 h3 h4 h5).include?(style[:style]) + @docx.public_send(style[:style], elem[:value]) + else + @docx.p elem[:value] do + align style[:align] + color style[:color] + bold style[:bold] + italic style[:italic] + end + end + elsif elem[:type] == 'image' + Reports::DocxRenderer.render_img_element(@docx, elem) + elsif %w(ul ol).include?(elem[:type]) + Reports::DocxRenderer.render_list_element(@docx, elem) + end + end + end + + private + + def combine_docx_elements(raw_elements) + # Word does not support some nested elements, move some elements to root level + elements = [] + temp_p = [] + raw_elements.each do |elem| + if %w(image newline table ol ul).include? elem[:type] + unless temp_p.empty? + elements.push(type: 'p', children: temp_p) + temp_p = [] + end + elements.push(elem) + elsif %w(br text a).include? elem[:type] + temp_p.push(elem) + end + end + elements.push(type: 'p', children: temp_p) + elements + end + + # Convert HTML structure to plain text structure + # rubocop:disable Metrics/BlockLength + def recursive_children(children, elements) + children.each do |elem| + if elem.class == Nokogiri::XML::Text + next if elem.text.strip == ' ' # Invisible symbol + + style = paragraph_styling(elem.parent) + type = (style[:align] && style[:align] != :justify) || style[:style] ? 'newline' : 'text' + + text = smart_annotation_check(elem) + + elements.push( + type: type, + value: text.strip.delete(' '), # Invisible symbol + style: style + ) + next + end + + if elem.name == 'br' + elements.push(type: 'br') + next + end + + if elem.name == 'img' + elements.push(img_element(elem)) + next + end + + if elem.name == 'a' + elements.push(link_element(elem)) + next + end + + if elem.name == 'table' + elements.push(tiny_mce_table_element(elem)) + next + end + + if %w(ul ol).include?(elem.name) + elements.push(list_element(elem)) + next + end + elements = recursive_children(elem.children, elements) if elem.children + end + elements + end + + # rubocop:enable Metrics/BlockLength + + def img_element(elem) + return unless elem.attributes['data-mce-token'] + + image = TinyMceAsset.find_by(id: Base62.decode(elem.attributes['data-mce-token'].value)) + return unless image + + image_path = Reports::Utils.image_path(image.image) + dimension = FastImage.size(image_path) + + return unless dimension + + style = image_styling(elem, dimension) + + { type: 'image', data: image_path.split('&')[0], blob: image.blob, style: style } + end + + def link_element(elem) + text = elem.text + link = elem.attributes['href'].value if elem.attributes['href'] + if elem.attributes['class']&.value == 'record-info-link' + link = nil + text = "##{text}" + end + text = "##{text}" if elem.parent.attributes['class']&.value == 'atwho-inserted' + text = "@#{text}" if elem.attributes['class']&.value == 'atwho-user-popover' + { + type: 'a', + value: text, + link: link + } + end + + def list_element(list_element) + allowed_elements = %w(li ul ol a img strong em h1 h2 h2 h3 h4 h5 span) + data_array = list_element.children.select { |n| allowed_elements.include?(n.name) }.map do |li_child| + li_child.children.map do |item| + if item.is_a? Nokogiri::XML::Text + item.text.chomp + elsif %w(ul ol).include?(item.name) + list_element(item) + elsif %w(a).include?(item.name) + link_element(item) + elsif %w(img).include?(item.name) + img_element(item)&.merge(bookmark_id: SecureRandom.hex) + elsif %w(table).include?(item.name) + tiny_mce_table_element(item).merge(bookmark_id: SecureRandom.hex) + elsif %w(strong em h1 h2 h2 h3 h4 h5 span).include?(item.name) + # Pass styles and extend renderer for li with style, some limitations on li items + # { type: 'text', value: item[:value], style: paragraph_styling(item) } + item.children.text + end + end.reject(&:blank?) + end + { type: list_element.name, data: data_array } + end + + def smart_annotation_check(elem) + return "[#{elem.text}]" if elem.parent.attributes['class']&.value == 'sa-type' + + elem.text + end + + # Prepare style for text + def paragraph_styling(elem) + style = elem.attributes['style'] + result = {} + result[:style] = elem.name if elem.name.include? 'h' + result[:bold] = true if elem.name == 'strong' + result[:italic] = true if elem.name == 'em' + style_keys = %w(text-align color) + + if style + style_keys.each do |key| + style_el = style.value.split(';').select { |i| (i.include? key) }[0] + next unless style_el + + value = style_el.split(':')[1].strip if style_el + if key == 'text-align' + result[:align] = value.to_sym + elsif key == 'color' && Reports::Utils.calculate_color_hsp(value) < 190 + result[:color] = value.delete('#') + end + end + end + result + end + + # Prepare style for images + def image_styling(elem, dimension) + dimension[0] = elem.attributes['width'].value.to_i if elem.attributes['width'] + dimension[1] = elem.attributes['height'].value.to_i if elem.attributes['height'] + + if elem.attributes['style'] + align = if elem.attributes['style'].value.include? 'margin-right' + :center + elsif elem.attributes['style'].value.include? 'float: right' + :right + else + :left + end + end + + margins = Constants::REPORT_DOCX_MARGIN_LEFT + Constants::REPORT_DOCX_MARGIN_RIGHT + max_width = (Constants::REPORT_DOCX_WIDTH - margins) / 20 + + if dimension[0] > max_width + x = max_width + y = dimension[1] * max_width / dimension[0] + else + x = dimension[0] + y = dimension[1] + end + + { + width: x, + height: y, + align: align, + max_width: max_width + } + end + + def tiny_mce_table_element(table_element) + # array of elements + rows = table_element.css('tbody').first.children.map do |row| + next unless row.name == 'tr' + + cells = row.children.map do |cell| + next unless cell.name == 'td' + + # Parse cell content + formated_cell = recursive_children(cell.children, []) + + # Combine text elements to single paragraph + formated_cell = combine_docx_elements(formated_cell) + formated_cell + end.reject(&:blank?) + { type: 'tr', data: cells } + end.reject(&:blank?) + { type: 'table', data: rows } + end + end +end diff --git a/app/services/reports/utils.rb b/app/services/reports/utils.rb new file mode 100644 index 000000000..b61f1cf1c --- /dev/null +++ b/app/services/reports/utils.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Reports + class Utils + def self.link_prepare(scinote_url, link) + link[0] == '/' ? scinote_url + link : link + end + + def self.image_path(attachment) + attachment.service_url + end + + def self.calculate_color_hsp(color) + return 255 if color.length != 7 + + color = color.delete('#').scan(/.{1,2}/) + rgb = color.map(&:hex) + Math.sqrt( + 0.299 * (rgb[0]**2) + + 0.587 * (rgb[1]**2) + + 0.114 * (rgb[2]**2) + ) + end + end +end diff --git a/app/views/my_modules/protocols.html.erb b/app/views/my_modules/protocols.html.erb index 882f6c144..10cdaaa6a 100644 --- a/app/views/my_modules/protocols.html.erb +++ b/app/views/my_modules/protocols.html.erb @@ -1,4 +1,12 @@ <% provide(:head_title, t("my_modules.protocols.head_title", project: h(@project.name), module: h(@my_module.name)).html_safe) %> +<% content_for :open_mobile_app_button do %> + + <%= link_to(pwa_mobile_app_url(@current_team.id, @project.id, @experiment.id, @my_module.id, @protocol.id, @protocol.first_step_id, request.host), + class: 'btn btn-light-link open-mobile-app-button') do %> + <%= t('my_modules.open_mobile_app') %> + <% end %> + +<% end %> <%= render partial: 'shared/drag_n_drop_overlay' %> <%= render partial: "shared/sidebar", locals: { current_task: @my_module, page: 'task' } %> @@ -132,3 +140,4 @@ <%= javascript_include_tag("my_modules/status_flow") %> <%= javascript_pack_tag 'emoji_button' %> <%= javascript_include_tag("my_modules/repositories") %> +<%= javascript_include_tag("my_modules/pwa_mobile_app") %> diff --git a/app/views/my_modules/repositories/_full_view_modal.html.erb b/app/views/my_modules/repositories/_full_view_modal.html.erb index 9990f8c35..fb21aa9e6 100644 --- a/app/views/my_modules/repositories/_full_view_modal.html.erb +++ b/app/views/my_modules/repositories/_full_view_modal.html.erb @@ -11,7 +11,8 @@ <%= @my_module.name %>
- + +
diff --git a/app/views/shared/_navigation.html.erb b/app/views/shared/_navigation.html.erb index f6c330f7b..d330e3bb0 100644 --- a/app/views/shared/_navigation.html.erb +++ b/app/views/shared/_navigation.html.erb @@ -12,8 +12,11 @@ <% end %> <%= link_to(root_path, class: 'navbar-brand', title: t('nav.label.scinote')) do %> - <%= image_tag('/images/scinote_icon.svg', id: 'logo') %> + + <%= image_tag('/images/scinote_icon.svg', id: 'logo') %> <% end %> + <%= yield :open_mobile_app_button %> + <% if user_signed_in? %> diff --git a/config/environments/development.rb b/config/environments/development.rb index d34cfb8ee..f0641f6e0 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -112,6 +112,9 @@ Rails.application.configure do # Enable sign in with LinkedIn account config.x.linkedin_signin_enabled = ENV['LINKEDIN_SIGNIN_ENABLED'] == 'true' + # Set up domain for pwa SciNote mobile app + config.x.pwa_domain = ENV['PWA_DOMAIN'] || 'm.scinote.net' + # Use an evented file watcher to asynchronously detect changes in source code, # routes, locales, etc. This feature depends on the listen gem. config.file_watcher = ActiveSupport::EventedFileUpdateChecker diff --git a/config/environments/production.rb b/config/environments/production.rb index e94b69fbf..71952274e 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -118,6 +118,9 @@ Rails.application.configure do # Enable sign in with LinkedIn account config.x.linkedin_signin_enabled = ENV['LINKEDIN_SIGNIN_ENABLED'] == 'true' + # Set up domain for pwa SciNote mobile app + config.x.pwa_domain = ENV['PWA_DOMAIN'] || 'm.scinote.net' + # Use a different logger for distributed setups. # require 'syslog/logger' # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index 0ac23b04a..13d74a977 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -38,6 +38,7 @@ Rails.application.config.assets.precompile += %w(my_modules/status_flow.js) Rails.application.config.assets.precompile += %w(my_modules/protocols/protocol_status_bar.js) Rails.application.config.assets.precompile += %w(my_modules/results.js) +Rails.application.config.assets.precompile += %w(my_modules/pwa_mobile_app.js) Rails.application.config.assets.precompile += %w(assets/wopi/create_wopi_file.js) Rails.application.config.assets.precompile += %w(results/result_tables.js) Rails.application.config.assets.precompile += %w(results/result_assets.js) diff --git a/config/initializers/constants.rb b/config/initializers/constants.rb index 30a4a4cfb..e3101a29e 100644 --- a/config/initializers/constants.rb +++ b/config/initializers/constants.rb @@ -207,6 +207,8 @@ class Constants ACADEMY_BL_LINK = 'https://scinote.net/academy/?utm_source=SciNote%20software%20BL&utm_medium=SciNote%20software%20BL'.freeze + PWA_URL = 'https://:pwa_domain/teams/:team_id/projects/:project_id/experiments/:experiment_id/tasks/:task_id/protocol/:protocol_id/:step_id?domain=:domain'.freeze + TWO_FACTOR_URL = { google: { android: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2', diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 3b1c1b5ed..3b9603bfa 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -5,12 +5,22 @@ # Read more: https://github.com/cyu/rack-cors -# Rails.application.config.middleware.insert_before 0, Rack::Cors do -# allow do -# origins 'example.com' -# -# resource '*', -# headers: :any, -# methods: [:get, :post, :put, :patch, :delete, :options, :head] -# end -# end +if ENV['SCINOTE_PWA_DOMAIN_NAME'].present? + Rails.application.config.middleware.insert_before 0, Rack::Cors do + allow do + origins ENV['SCINOTE_PWA_DOMAIN_NAME'] + + resource '/oauth/token', + headers: :any, + methods: %i(post) + + resource '/rails/active_storage/*', + headers: :any, + methods: %i(get post options head) + + resource '/api/*', + headers: :any, + methods: %i(get post put patch delete options head) + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index a2f4d4a50..a789a1c80 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -453,6 +453,10 @@ en: nothing_selected: "Nothing selected" generate_PDF: generated_on: "Report generated by SciNote on: %{timestamp}" + renderers: + lists: + appended_image: "Appended image - %{name}" + appended_table: "Appended table" elements: modals: project_contents: @@ -811,8 +815,8 @@ en: head_title: "%{project} | %{module} | Inventory %{repository}" export: 'Export' full_view: - modal_live_header: '%{repository_name}: Live version' - modal_snapshot_header: '%{repository_name}: Snapshot of %{snaphot_date}' + modal_live_header: ': Live version' + modal_snapshot_header: ': Snapshot of %{snaphot_date}' assign_modal_header: 'Assign from %{repository_name} inventory' snapshots: simple_view: @@ -865,6 +869,7 @@ en: unshared_inventory: title_html: The inventory %{inventory_name} is no longer shared with your team. body_html: This inventory has been ushared with your team by the inventory’s owner. To view the item/s that are assigned to your task/s contact the %{team_name} team administrator %{admin_name} (%{admin_email}). + open_mobile_app: "Open mobile app" experiments: new: create: 'New Experiment' diff --git a/public/images/doorkeeper_auth.png b/public/images/doorkeeper_auth.png new file mode 100644 index 000000000..46d1cd682 Binary files /dev/null and b/public/images/doorkeeper_auth.png differ diff --git a/public/images/sn-icon.svg b/public/images/sn-icon.svg new file mode 100644 index 000000000..46cce9aad --- /dev/null +++ b/public/images/sn-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/spec/services/reports/html_to_word_converter_spec.rb b/spec/services/reports/html_to_word_converter_spec.rb new file mode 100644 index 000000000..1393d7892 --- /dev/null +++ b/spec/services/reports/html_to_word_converter_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Reports::HtmlToWordConverter do + let(:user) { create :user } + let(:team) { create :team } + let(:docx) { double('docx') } + let(:report) { described_class.new(docx) } + + describe 'html_list' do + let(:text) do + '' + end + let(:xml_elements) { Nokogiri::HTML(text).css('body').children.first } + let(:result) do + { + type: 'ul', + data: [%w(1), + ['2', { type: 'ul', data: [%w(one), ['two', { type: 'ol', data: [%w(uno), %w(due)] }]] }], + %w(3), %w(4), %w(5)] + } + end + it '' do + expect(report.__send__(:list_element, xml_elements)).to be == result + end + end + + describe '.tiny_mce_table_element' do + let(:text) do + # rubocop:disable Layout/LineLength + '
12
34
' + # rubocop:enable Layout/LineLength + end + let(:xml_elements) { Nokogiri::HTML(text).css('body').children.first } + let(:result) do + { + data: [ + { + data: [ + [{ children: [{ style: {}, type: 'text', value: '1' }], type: 'p' }], + [{ children: [{ style: {}, type: 'text', value: '2' }], type: 'p' }] + ], + type: 'tr' + }, + { + data: [ + [{ children: [{ style: {}, type: 'text', value: '3' }], type: 'p' }], + [{ children: [{ style: {}, type: 'text', value: '4' }], type: 'p' }] + ], + type: 'tr' + } + ], + type: 'table' + } + end + + it '' do + expect(report.__send__(:tiny_mce_table_element, xml_elements)).to be == result + end + end +end