diff --git a/app/helpers/additionals_chartjs_helper.rb b/app/helpers/additionals_chartjs_helper.rb index f80275cd..90fe0b5b 100644 --- a/app/helpers/additionals_chartjs_helper.rb +++ b/app/helpers/additionals_chartjs_helper.rb @@ -8,7 +8,7 @@ module AdditionalsChartjsHelper end def select_options_for_chartjs_colorscheme(selected) - data = YAML.safe_load(ERB.new(File.read(File.join(Additionals.plugin_dir, 'config', 'colorschemes.yml'))).result) || {} + data = YAML.safe_load(ERB.new(File.read(File.join(AdditionalsLoader.plugin_dir, 'config', 'colorschemes.yml'))).result) || {} grouped_options_for_select data, selected end end diff --git a/app/helpers/additionals_wiki_pdf_helper.rb b/app/helpers/additionals_wiki_helper.rb similarity index 97% rename from app/helpers/additionals_wiki_pdf_helper.rb rename to app/helpers/additionals_wiki_helper.rb index da9597e1..4d4c062c 100644 --- a/app/helpers/additionals_wiki_pdf_helper.rb +++ b/app/helpers/additionals_wiki_helper.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module AdditionalsWikiPdfHelper +module AdditionalsWikiHelper include Redmine::Export::PDF def wiki_page_to_pdf(page, project) diff --git a/app/models/additionals_font_awesome.rb b/app/models/additionals_font_awesome.rb index e2e1f072..c9a5d633 100644 --- a/app/models/additionals_font_awesome.rb +++ b/app/models/additionals_font_awesome.rb @@ -8,7 +8,7 @@ class AdditionalsFontAwesome class << self def load_icons(type) - data = YAML.safe_load(ERB.new(File.read(File.join(Additionals.plugin_dir, 'config', 'fontawesome_icons.yml'))).result) || {} + data = YAML.safe_load(ERB.new(File.read(File.join(AdditionalsLoader.plugin_dir, 'config', 'fontawesome_icons.yml'))).result) || {} icons = {} data.each do |key, values| icons[key] = { unicode: values['unicode'], label: values['label'] } if values['styles'].include? convert_type2style(type) diff --git a/app/models/additionals_loader.rb b/app/models/additionals_loader.rb new file mode 100644 index 00000000..f3c0dd99 --- /dev/null +++ b/app/models/additionals_loader.rb @@ -0,0 +1,244 @@ +# frozen_string_literal: true + +class AdditionalsLoader + class ExistingControllerPatchForHelper < StandardError; end + + attr_accessor :plugin_id, :debug + + class << self + def default_settings(plugin_id = 'additionals') + cached_settings_name = "@default_settings_#{plugin_id}" + cached_settings = instance_variable_get cached_settings_name + if cached_settings.nil? + data = YAML.safe_load(ERB.new(File.read(File.join(plugin_dir(plugin_id), '/config/settings.yml'))).result) || {} + instance_variable_set cached_settings_name, data.symbolize_keys + else + cached_settings + end + end + + def plugin_dir(plugin_id = 'additionals') + if Gem.loaded_specs[plugin_id].nil? + File.join Redmine::Plugin.directory, plugin_id + else + Gem.loaded_specs[plugin_id].full_gem_path + end + end + + def to_prepare(*args, &block) + if Rails.version > '6.0' + # TODO: This does not work + # see https://www.redmine.org/issues/36245 + ActiveSupport.on_load(:active_record, &block) + else + # ActiveSupport::Reloader.to_prepare(*args, &block) + Rails.configuration.to_prepare(*args, &block) + end + end + + def persisting + Additionals.debug 'Loading persisting...' + yield + end + + def after_initialize(&block) + Additionals.debug 'After initialize...' + Rails.application.config.after_initialize(&block) + end + + # required multiple times because of this bug: https://www.redmine.org/issues/33290 + def redmine_database_ready?(with_table = nil) + ActiveRecord::Base.connection + rescue ActiveRecord::NoDatabaseError + false + else + with_table.nil? || ActiveRecord::Base.connection.table_exists?(with_table) + end + end + + def initialize(plugin_id: 'additionals', debug: false) + self.plugin_id = plugin_id + self.debug = debug + + apply_reset + end + + def apply_reset + @patches = [] + @helpers = [] + @global_helpers = [] + end + + def plugin_dir + @plugin_dir ||= self.class.plugin_dir plugin_id + end + + # use_app: false => :plugin_dir/lib/:plugin_id directory + def require_files(spec, use_app: false, reverse: false) + dir = if use_app + File.join plugin_dir, 'app', spec + else + File.join plugin_dir, 'lib', plugin_id, spec + end + + files = Dir[dir].sort + + files.reverse! if reverse + files.each { |f| require f } + end + + def incompatible?(plugins = []) + plugins.each do |plugin| + raise "\n\033[31m#{plugin_id} plugin cannot be used with #{plugin} plugin.\033[0m" if Redmine::Plugin.installed? plugin + end + end + + def load_macros! + require_files File.join('wiki_macros', '**/*_macro.rb') + end + + def load_hooks! + target = plugin_id.camelize.constantize + target::Hooks + end + + def load_custom_field_format!(reverse: false) + require_files File.join('custom_field_formats', '**/*_format.rb'), + reverse: reverse + end + + def add_patch(patch) + if patch.is_a? Array + @patches += patch + else + @patches << patch + end + end + + def add_helper(helper) + if helper.is_a? Array + @helpers += helper + else + @helpers << helper + end + end + + def add_global_helper(helper) + if helper.is_a? Array + @global_helpers += helper + else + @global_helpers << helper + end + end + + def apply! + validate_apply + + apply_patches! + apply_helpers! + apply_global_helpers! + + # reset patches and helpers + apply_reset + + true + end + + private + + def validate_apply + return if @helpers.none? || @patches.none? + + controller_patches = @patches.select do |p| + if p.is_a? String + true unless p == p.chomp('Controller') + else + c = p[:target].to_s + true unless c == c.chomp('Controller') + end + end + + @helpers.each do |h| + helper_controller = if h.is_a? String + "#{h}Controller" + else + c = h[:controller] + if c.is_a? String + "#{c}Controller" + else + c.to_s + end + end + + if controller_patches.include? helper_controller + raise ExistingControllerPatchForHelper, "Do not add helper to #{helper_controller} if patch exists (#{plugin_id})" + end + end + end + + def apply_patches! + patches = @patches.map do |p| + if p.is_a? String + { target: p.constantize, patch: p } + else + patch = p[:patch] || p[:target].to_s + { target: p[:target], patch: patch } + end + end + + patches.uniq! + Additionals.debug "patches for #{plugin_id}: #{patches.inspect}" if debug + + patches.each do |patch| + patch_module = if patch[:patch].is_a? String + patch_dir = Rails.root.join "plugins/#{plugin_id}/lib/#{plugin_id}/patches" + require "#{patch_dir}/#{patch[:patch].underscore}_patch" + "#{plugin_id.camelize}::Patches::#{patch[:patch]}Patch".constantize + else + # if module specified (if not string), use it + patch[:patch] + end + + target = patch[:target] + target.include patch_module unless target.included_modules.include? patch_module + end + end + + def apply_helpers! + helpers = @helpers.map do |h| + if h.is_a? String + { controller: "#{h}Controller".constantize, helper: "#{plugin_id.camelize}#{h}Helper".constantize } + else + c = h[:controller].is_a?(String) ? "#{h[:controller]}Controller".constantize : h[:controller] + helper = if h[:helper] + h[:helper] + else + helper_name = if h[:controller].is_a? String + h[:controller] + else + h[:controller].to_s.chomp 'Controller' + end + "#{plugin_id.camelize}#{helper_name}Helper".constantize + end + { controller: c, helper: helper } + end + end + + helpers.uniq! + Additionals.debug "helpers for #{plugin_id}: #{helpers.inspect}" if debug + + helpers.each do |h| + target = h[:controller] + target.send :helper, h[:helper] + end + end + + def apply_global_helpers! + global_helpers = @global_helpers.uniq + Additionals.debug "global helpers for #{plugin_id}: #{global_helpers.inspect}" if debug + + global_helpers.each do |h| + ActionView::Base.include h + end + end +end diff --git a/init.rb b/init.rb index bbc7e3b8..24b04ae2 100644 --- a/init.rb +++ b/init.rb @@ -11,7 +11,7 @@ Redmine::Plugin.register :additionals do url 'https://github.com/alphanodes/additionals' directory __dir__ - default_settings = Additionals.load_settings + default_settings = AdditionalsLoader.default_settings 5.times do |i| default_settings["custom_menu#{i}_name"] = '' default_settings["custom_menu#{i}_url"] = '' @@ -51,20 +51,23 @@ Redmine::Plugin.register :additionals do menu :admin_menu, :additionals, { controller: 'settings', action: 'plugin', id: 'additionals' }, caption: :label_additionals end -Rails.application.config.after_initialize do +AdditionalsLoader.persisting do + Rails.application.paths['app/overrides'] ||= [] + Dir.glob(Rails.root.join('plugins/*/app/overrides')).each do |dir| + Rails.application.paths['app/overrides'] << dir unless Rails.application.paths['app/overrides'].include? dir + end + + Redmine::AccessControl.include Additionals::Patches::AccessControlPatch + Redmine::AccessControl.singleton_class.prepend Additionals::Patches::AccessControlClassPatch +end + +AdditionalsLoader.after_initialize do # @TODO: this should be moved to AdditionalsFontAwesome and use an instance of it FONTAWESOME_ICONS = { fab: AdditionalsFontAwesome.load_icons(:fab), # rubocop: disable Lint/ConstantDefinitionInBlock far: AdditionalsFontAwesome.load_icons(:far), fas: AdditionalsFontAwesome.load_icons(:fas) }.freeze end -Rails.application.paths['app/overrides'] ||= [] -Dir.glob(Rails.root.join('plugins/*/app/overrides')).each do |dir| - Rails.application.paths['app/overrides'] << dir unless Rails.application.paths['app/overrides'].include? dir -end - -if Rails.version > '6.0' - ActiveSupport.on_load(:active_record) { Additionals.setup } -else - Rails.configuration.to_prepare { Additionals.setup } +AdditionalsLoader.to_prepare do + Additionals.setup end diff --git a/lib/additionals.rb b/lib/additionals.rb index cb5828cc..feb9d52e 100644 --- a/lib/additionals.rb +++ b/lib/additionals.rb @@ -11,58 +11,58 @@ module Additionals def setup RenderAsync.configuration.jquery = true - incompatible_plugins %w[redmine_editauthor + loader = AdditionalsLoader.new + + loader.incompatible? %w[redmine_editauthor redmine_changeauthor redmine_auto_watch] - ApplicationController.include Additionals::Patches::ApplicationControllerPatch - AutoCompletesController.include Additionals::Patches::AutoCompletesControllerPatch - Issue.include Additionals::Patches::IssuePatch - IssuePriority.include Additionals::Patches::IssuePriorityPatch - TimeEntry.include Additionals::Patches::TimeEntryPatch - Project.include Additionals::Patches::ProjectPatch - Wiki.include Additionals::Patches::WikiPatch - ProjectsController.include Additionals::Patches::ProjectsControllerPatch - WelcomeController.include Additionals::Patches::WelcomeControllerPatch - ReportsController.include Additionals::Patches::ReportsControllerPatch - Principal.include Additionals::Patches::PrincipalPatch - Query.include Additionals::Patches::QueryPatch - QueryFilter.include Additionals::Patches::QueryFilterPatch - Role.include Additionals::Patches::RolePatch - User.include Additionals::Patches::UserPatch - UserPreference.include Additionals::Patches::UserPreferencePatch + loader.add_patch %w[ApplicationController + AutoCompletesController + Issue + IssuePriority + TimeEntry + Project + Wiki + ProjectsController + WelcomeController + ReportsController + Principal + Query + QueryFilter + Role + User + UserPreference] - IssuesController.send :helper, AdditionalsIssuesHelper - SettingsController.send :helper, AdditionalsSettingsHelper - WikiController.send :helper, AdditionalsWikiPdfHelper - CustomFieldsController.send :helper, AdditionalsCustomFieldsHelper + loader.add_helper %w[Issues + Settings + Wiki + CustomFields] + + loader.add_global_helper [Additionals::Helpers, + AdditionalsFontawesomeHelper, + AdditionalsMenuHelper, + AdditionalsSelect2Helper] Redmine::WikiFormatting.format_names.each do |format| case format when 'markdown' - Redmine::WikiFormatting::Markdown::HTML.include Patches::FormatterMarkdownPatch - Redmine::WikiFormatting::Markdown::Helper.include Patches::FormattingHelperPatch + loader.add_patch [{ target: Redmine::WikiFormatting::Markdown::HTML, patch: 'FormatterMarkdown' }, + { target: Redmine::WikiFormatting::Markdown::Helper, patch: 'FormattingHelper' }] when 'textile' - Redmine::WikiFormatting::Textile::Formatter.include Patches::FormatterTextilePatch - Redmine::WikiFormatting::Textile::Helper.include Patches::FormattingHelperPatch + loader.add_patch [{ target: Redmine::WikiFormatting::Textile::Formatter, patch: 'FormatterTextile' }, + { target: Redmine::WikiFormatting::Textile::Helper, patch: 'FormattingHelper' }] end end - # Static class patches - Redmine::AccessControl.include Additionals::Patches::AccessControlPatch - Redmine::AccessControl.singleton_class.prepend Additionals::Patches::AccessControlClassPatch - - # Global helpers - ActionView::Base.include Additionals::Helpers - ActionView::Base.include AdditionalsFontawesomeHelper - ActionView::Base.include AdditionalsMenuHelper - ActionView::Base.include AdditionalsSelect2Helper + # Apply patches and helper + loader.apply! # Macros - load_macros + loader.load_macros! # Hooks - Additionals::Hooks + loader.load_hooks! end # support with default setting as fall back @@ -70,7 +70,7 @@ module Additionals if settings.key? value settings[value] else - load_settings[value] + AdditionalsLoader.default_settings[value] end end @@ -78,15 +78,6 @@ module Additionals true? setting(value) end - # required multiple times because of this bug: https://www.redmine.org/issues/33290 - def redmine_database_ready?(with_table = nil) - ActiveRecord::Base.connection - rescue ActiveRecord::NoDatabaseError - false - else - with_table.nil? || ActiveRecord::Base.connection.table_exists?(with_table) - end - def true?(value) return false if value.is_a? FalseClass return true if value.is_a?(TrueClass) || value.to_i == 1 || value.to_s.casecmp('true').zero? @@ -124,62 +115,6 @@ module Additionals timezone.utc_offset - Time.zone.local_to_utc(time).localtime.utc_offset end - def incompatible_plugins(plugins = [], title = 'additionals') - plugins.each do |plugin| - raise "\n\033[31m#{title} plugin cannot be used with #{plugin} plugin.\033[0m" if Redmine::Plugin.installed? plugin - end - end - - # obsolete, do not use this method (it will be removed in next major release) - def patch(patches = [], plugin_id = 'additionals') - patches.each do |name| - patch_dir = Rails.root.join "plugins/#{plugin_id}/lib/#{plugin_id}/patches" - require "#{patch_dir}/#{name.underscore}_patch" - - target = name.constantize - patch = "#{plugin_id.camelize}::Patches::#{name}Patch".constantize - - target.include patch unless target.included_modules.include? patch - end - end - - def load_macros(plugin_id = 'additionals') - Dir[File.join(plugin_dir(plugin_id), - 'lib', - plugin_id, - 'wiki_macros', - '**/*_macro.rb')].sort.each { |f| require f } - end - - def load_custom_field_format(plugin_id, reverse: false) - files = Dir[File.join(plugin_dir(plugin_id), - 'lib', - plugin_id, - 'custom_field_formats', - '**/*_format.rb')].sort - files.reverse! if reverse - files.each { |f| require f } - end - - def plugin_dir(plugin_id = 'additionals') - if Gem.loaded_specs[plugin_id].nil? - File.join Redmine::Plugin.directory, plugin_id - else - Gem.loaded_specs[plugin_id].full_gem_path - end - end - - def load_settings(plugin_id = 'additionals') - cached_settings_name = "@load_settings_#{plugin_id}" - cached_settings = instance_variable_get cached_settings_name - if cached_settings.nil? - data = YAML.safe_load(ERB.new(File.read(File.join(plugin_dir(plugin_id), '/config/settings.yml'))).result) || {} - instance_variable_set cached_settings_name, data.symbolize_keys - else - cached_settings - end - end - def hash_remove_with_default(field, options, default = nil) value = nil if options.key? field diff --git a/lib/additionals/patches/access_control_patch.rb b/lib/additionals/patches/access_control_patch.rb index 05f600b6..fdecf982 100644 --- a/lib/additionals/patches/access_control_patch.rb +++ b/lib/additionals/patches/access_control_patch.rb @@ -11,7 +11,7 @@ module Additionals end def disabled_project_modules - @database_ready = (Additionals.redmine_database_ready? Setting.table_name) unless defined? @database_ready + @database_ready = (AdditionalsLoader.redmine_database_ready? Setting.table_name) unless defined? @database_ready return [] unless @database_ready mods = Additionals.setting(:disabled_modules).to_a.reject(&:blank?) diff --git a/test/unit/additionals_loader_test.rb b/test/unit/additionals_loader_test.rb new file mode 100644 index 00000000..184046ac --- /dev/null +++ b/test/unit/additionals_loader_test.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require File.expand_path '../../test_helper', __FILE__ + +class AdditionalsLoaderTest < Additionals::TestCase + def test_add_patch + loader = AdditionalsLoader.new + loader.add_patch 'Issue' + + assert loader.apply! + end + + def test_add_patch_as_hash + loader = AdditionalsLoader.new + loader.add_patch({ target: Issue, patch: 'Issue' }) + + assert loader.apply! + end + + def test_add_patch_as_hash_without_patch + loader = AdditionalsLoader.new + loader.add_patch({ target: Issue }) + + assert loader.apply! + end + + def test_add_multiple_patches + loader = AdditionalsLoader.new + loader.add_patch %w[Issue User] + + assert loader.apply! + end + + def test_add_invalid_patch + loader = AdditionalsLoader.new + loader.add_patch 'Issue2' + + assert_raises NameError do + loader.apply! + end + end + + def test_add_helper + loader = AdditionalsLoader.new + loader.add_helper 'Settings' + + assert loader.apply! + end + + def test_add_helper_as_hash + loader = AdditionalsLoader.new + loader.add_helper({ controller: SettingsController, helper: SettingsHelper }) + + assert loader.apply! + end + + def test_add_helper_as_hash_as_string + loader = AdditionalsLoader.new + loader.add_helper({ controller: 'Settings', helper: 'Settings' }) + + assert loader.apply! + end + + def test_add_helper_as_hash_controller_only + loader = AdditionalsLoader.new + loader.add_helper({ controller: SettingsController }) + + assert loader.apply! + end + + def test_add_helper_as_hash_controller_only_string + loader = AdditionalsLoader.new + loader.add_helper({ controller: 'Settings' }) + + assert loader.apply! + end + + def test_load_macros + loader = AdditionalsLoader.new + macros = loader.load_macros! + + assert macros.count.positive? + assert(macros.detect { |macro| macro.include? 'fa_macro' }) + end + + def test_load_hooks + loader = AdditionalsLoader.new + hooks = loader.load_hooks! + + assert hooks.is_a? Module + end + + def test_require_files_for_lib + loader = AdditionalsLoader.new + + spec = File.join 'wiki_macros', '**/*_macro.rb' + files = loader.require_files spec + + assert files.count.positive? + assert(files.detect { |file| file.include? 'fa_macro' }) + end + + def test_require_files_for_app + loader = AdditionalsLoader.new + + spec = File.join 'helpers', '**/additionals_*.rb' + files = loader.require_files spec, use_app: true + + assert files.count.positive? + assert(files.detect { |file| file.include? 'additionals_clipboardjs_helper' }) + end + + def test_apply_without_data + loader = AdditionalsLoader.new + assert loader.apply! + end + + def test_apply + loader = AdditionalsLoader.new + loader.add_helper 'Settings' + loader.add_patch 'Issue' + loader.add_global_helper Additionals::Helpers + assert loader.apply! + end + + def test_do_not_allow_helper_if_controller_patch_exists + loader = AdditionalsLoader.new + loader.add_patch 'ProjectsController' + loader.add_helper 'Projects' + + assert_raises AdditionalsLoader::ExistingControllerPatchForHelper do + assert loader.apply! + end + end + + def test_do_not_allow_helper_if_controller_patch_exists_as_hash + loader = AdditionalsLoader.new + loader.add_patch 'ProjectsController' + loader.add_helper({ controller: ProjectsController, helper: 'Settings' }) + + assert_raises AdditionalsLoader::ExistingControllerPatchForHelper do + assert loader.apply! + end + end +end diff --git a/test/unit/additionals_test.rb b/test/unit/additionals_test.rb index f2cfb485..cb28b7e7 100644 --- a/test/unit/additionals_test.rb +++ b/test/unit/additionals_test.rb @@ -62,13 +62,6 @@ class AdditionalsTest < Additionals::TestCase assert_not Additionals.setting?(:add_go_to_top) end - def test_load_macros - macros = Additionals.load_macros - - assert macros.count.positive? - assert(macros.detect { |macro| macro.include? 'fa_macro' }) - end - def test_split_ids assert_equal [1, 2, 3], Additionals.split_ids('1, 2 , 3') assert_equal [3, 2], Additionals.split_ids('3, 2, 2')