Project

General

Profile

Feature #27705 » 0001v3-Enable-to-use-gem-as-Redmine-plugin-and-theme.patch

Takashi Kato, 2025-09-24 16:19

View differences:

.gitignore
43 43
/.bundle
44 44
/Gemfile.lock
45 45
/Gemfile.local
46
/Gemfile.extension
46 47

  
47 48
/node_modules
48 49
yarn-error.log
Gemfile
130 130
end
131 131

  
132 132
# Load plugins' Gemfiles
133
Dir.glob File.expand_path("../plugins/*/{Gemfile,PluginGemfile}", __FILE__) do |file|
134
  eval_gemfile file
133
Dir.glob(File.expand_path("plugins/*/", __dir__)) do |entry|
134
  next unless File.directory?(entry)
135

  
136
  plugin_dir = File.expand_path(entry, __dir__)
137
  spec = File.join plugin_dir, '*.gemspec'
138
  files =
139
    if Dir.exist? spec
140
      Dir.glob(File.join(plugin_dir, "PluginGemfile"))
141
    else
142
      Dir.glob(File.join(plugin_dir, "{Gemfile,PluginGemfile}"))
143
    end
144
  files.each do |file|
145
    eval_gemfile file
146
  end
147
end
148

  
149
extension_gemfile = File.join(File.dirname(__FILE__), "Gemfile.extension")
150
if File.exist?(extension_gemfile)
151
  group :redmine_extension do
152
    eval_gemfile extension_gemfile
153
  end
135 154
end
Gemfile.extension.example
1
# vim: set ft=ruby
2
# Copy this file to Gemfile.extension and add any rubygem plugins
3

  
4
# Example:
5
#   gem 'redmine_sample_plugin', '~> 1.1.0'
config/routes.rb
427 427
  # Can be used by load balancers and uptime monitors to verify that the app is live.
428 428
  get "up" => "rails/health#show", :as => :rails_health_check
429 429

  
430
  Redmine::Plugin.directory.glob("*/config/routes.rb").sort.each do |plugin_routes_path|
431
    instance_eval(plugin_routes_path.read, plugin_routes_path.to_s)
430
  Redmine::PluginLoader.directories.each do |plugin_path|
431
    next unless plugin_path.routes
432

  
433
    instance_eval plugin_path.routes.read, plugin_path.routes.to_s
432 434
  rescue SyntaxError, StandardError => e
433
    plugin_name = plugin_routes_path.parent.parent.basename.to_s
434
    puts "An error occurred while loading the routes definition of #{plugin_name} plugin (#{plugin_routes_path}): #{e.message}."
435
    plugin_name = File.basename plugin_path.to_s
436
    puts "An error occurred while loading the routes definition of #{plugin_name} plugin (#{plugin_path.routes}): #{e.message}."
435 437
    exit 1
436 438
  end
437 439
end
doc/RUNNING_TESTS
21 21
Before running tests, you need to configure both development
22 22
and test databases.
23 23

  
24
To test if a gem plugin or gem theme is loaded, do the following
25

  
26
`export BUNDLE_GEMFILE=./test/fixtures/gem_extensions/Gemfile`
27
`bundle install`
28
`bin/rails test test/unit/lib/redmine/plugin_test.rb`
29
`bin/rails test test/unit/lib/redmine/theme_test.rb`
30

  
24 31
Creating test repositories
25 32
==========================
26 33

  
lib/redmine/plugin.rb
24 24
  # Exception raised when a plugin requirement is not met.
25 25
  class PluginRequirementError < StandardError; end
26 26

  
27
  # Exception raised when plugin id and gemspec metadata is not met.
28
  class InvalidPluginId < StandardError; end
29

  
27 30
  # Base class for Redmine plugins.
28 31
  # Plugins are registered using the <tt>register</tt> class method that acts as the public constructor.
29 32
  #
......
94 97
      p = new(id)
95 98
      p.instance_eval(&)
96 99

  
97
      # Set a default name if it was not provided during registration
98
      p.name(id.to_s.humanize) if p.name.nil?
99
      # Set a default directory if it was not provided during registration
100
      p.directory(File.join(self.directory, id.to_s)) if p.directory.nil?
100
      # Set a default attributes if it was not provided during registration
101
      p.set_default_attrs File.join(self.directory, id.to_s)
101 102

  
102 103
      unless File.directory?(p.directory)
103 104
        raise PluginNotFound, "Plugin not found. The directory for plugin #{p.id} should be #{p.directory}."
104 105
      end
105 106

  
106
      p.path = PluginLoader.directories.find {|d| d.to_s == p.directory}
107
      loc =  caller_locations(1, 1).first.absolute_path
108
      if p.has_initializer? && (File.dirname(loc) != p.directory)
109
        raise InvalidPluginId, "The location of init.rb is different from #{p.directory}. It called from #{loc}"
110
      end
107 111

  
108 112
      # Adds plugin locales if any
109 113
      # YAML translation files should be found under <plugin>/config/locales/
......
135 139
        @used_partials[partial] = p.id
136 140
      end
137 141

  
142
      # Load dependencies
143
      if p.gem?
144
        p.path.gemspec.dependencies.each do |d|
145
          PluginLoader.dependencies << d.name
146
        end
147
      end
148

  
138 149
      registered_plugins[id] = p
139 150
    end
140 151

  
......
203 214
      self.id.to_s <=> plugin.id.to_s
204 215
    end
205 216

  
217
    def set_default_attrs(default_dir)
218
      dir = directory || default_dir
219
      self.path = PluginLoader.find_path(plugin_id: id, plugin_dir: dir)
220

  
221
      if gem?
222
        spec = path.gemspec
223
        name spec.summary if spec.summary
224
        author spec.authors.join(",") if spec.authors
225
        description spec.description if spec.description
226
        version spec.version.to_s if spec.version
227
        url spec.homepage if spec.homepage
228
        author_url spec.metadata["author_url"] if spec.metadata["author_url"]
229
      end
230

  
231
      # Set a default name if it was not provided during registration
232
      name(id.to_s.humanize) if name.nil?
233

  
234
      # Set a default directory if it was not provided during registration
235
      if directory.nil?
236
        if path.present?
237
          directory(path.to_s)
238
        else
239
          directory(default_dir)
240
        end
241
      end
242
    end
243

  
244
    def gem?
245
      path && path.gemspec.present?
246
    end
247

  
248
    def has_initializer?
249
      path.present? && path.has_initializer?
250
    end
251

  
206 252
    # Sets a requirement on Redmine version
207 253
    # Raises a PluginRequirementError exception if the requirement is not met
208 254
    #
lib/redmine/plugin_loader.rb
19 19

  
20 20
module Redmine
21 21
  class PluginPath
22
    attr_reader :assets_dir, :initializer
22
    attr_reader :assets_dir, :initializer, :gemspec
23 23

  
24
    def initialize(dir)
24
    def initialize(dir, gemspec = nil)
25 25
      @dir = dir
26
      @gemspec = gemspec
26 27
      @assets_dir = File.join dir, 'assets'
27 28
      @initializer = File.join dir, 'init.rb'
29
      add_autoload_paths
30
    end
31

  
32
    def add_autoload_paths
33
      # Add the plugin directories to rails autoload paths
34
      engine_cfg = Rails::Engine::Configuration.new(self.to_s)
35
      engine_cfg.paths.add 'lib', eager_load: true
36
      engine_cfg.all_eager_load_paths.each do |dir|
37
        Rails.autoloaders.main.push_dir dir
38
        Rails.application.config.watchable_dirs[dir] = [:rb]
39
      end
28 40
    end
29 41

  
30 42
    def run_initializer
......
42 54
    def has_initializer?
43 55
      File.file?(@initializer)
44 56
    end
57

  
58
    def routes
59
      file = Pathname.new(@dir).join('config/routes.rb')
60
      if file.exist?
61
        file
62
      else
63
        nil
64
      end
65
    end
45 66
  end
46 67

  
47 68
  class PluginLoader
69
    class PluginIdDuplicated < StandardError; end
48 70
    # Absolute path to the directory where plugins are located
49 71
    cattr_accessor :directory
50 72
    self.directory = Rails.root.join Rails.application.config.redmine_plugins_directory
......
55 77

  
56 78
    def self.load
57 79
      setup
58
      add_autoload_paths
59 80

  
60 81
      Rails.application.config.to_prepare do
61 82
        PluginLoader.directories.each(&:run_initializer)
83
        PluginLoader.require_dependencies
62 84

  
63 85
        Redmine::Hook.call_hook :after_plugins_loaded
64 86
      end
......
67 89
    def self.setup
68 90
      @plugin_directories = []
69 91

  
70
      Dir.glob(File.join(directory, '*')).each do |directory|
71
        next unless File.directory?(directory)
92
      Dir.glob(File.join(directory, '*')).each do |dir|
93
        next unless File.directory?(dir)
72 94

  
73
        @plugin_directories << PluginPath.new(directory)
95
        @plugin_directories << PluginPath.new(dir)
74 96
      end
75
    end
76 97

  
77
    def self.add_autoload_paths
78
      directories.each do |directory|
79
        # Add the plugin directories to rails autoload paths
80
        engine_cfg = Rails::Engine::Configuration.new(directory.to_s)
81
        engine_cfg.paths.add 'lib', eager_load: true
82
        engine_cfg.all_eager_load_paths.each do |dir|
83
          Rails.autoloaders.main.push_dir dir
84
          Rails.application.config.watchable_dirs[dir] = [:rb]
98
      # If there are plugins under plugins/, do not register a gem with the same name.
99
      plugin_specs.each do |spec|
100
        dir = File.join(directory, spec.name)
101
        if File.directory?(dir)
102
          warn "WARN: \"#{spec.name}\" plugin installed as gems also exist in the \"#{dir}\" directory; use the ones in \"#{dir}\"."
103
          next
85 104
        end
105
        @plugin_directories << PluginPath.new(spec.full_gem_path, spec)
106
      end
107
    end
108

  
109
    def self.plugin_specs
110
      specs = Bundler.definition
111
                     .specs_for([:redmine_extension])
112
                     .to_a
113
                     .select{|s| s.name != 'bundler' && !s.metadata['redmine_plugin_id'].nil?}
114
      duplicates = specs.group_by{|s| s.metadata['redmine_plugin_id']}.reject{|k, v| v.one?}.keys
115
      raise PluginIdDuplicated.new("#{duplicates.join(',')} Duplicate plugin id") unless duplicates.empty?
116

  
117
      specs
118
    end
119

  
120
    cattr_accessor :dependencies
121
    self.dependencies = []
122

  
123
    def self.require_dependencies
124
      # Load dependencies. If the dependency is a redmine plugin, do not load it
125
      # (it should already be initialized)
126
      deps = dependencies - plugin_specs.map(&:name)
127
      deps.uniq.each do |d|
128
        require d
129
      end
130
    end
131

  
132
    def self.find_path(plugin_id:, plugin_dir:)
133
      path = directories.find {|d| d.gemspec.present? && d.gemspec.metadata['redmine_plugin_id'] == plugin_id.to_s }
134
      if path.nil?
135
        path = directories.find {|d| d.to_s == plugin_dir}
86 136
      end
137
      path
87 138
    end
88 139

  
89 140
    def self.directories
lib/redmine/themes.rb
19 19

  
20 20
module Redmine
21 21
  module Themes
22
    class ThemeIdDuplicated < StandardError; end
23

  
22 24
    # Return an array of installed themes
23 25
    def self.themes
24 26
      @@installed_themes ||= scan_themes
......
43 45

  
44 46
    # Class used to represent a theme
45 47
    class Theme
46
      attr_reader :path, :name, :dir
48
      attr_reader :path, :name, :dir, :gemspec
47 49

  
48
      def initialize(path)
50
      def initialize(path, gemspec = nil)
49 51
        @path = path
50 52
        @dir = File.basename(path)
51 53
        @name = @dir.humanize
52 54
        @stylesheets = nil
53 55
        @javascripts = nil
56
        @gemspec = gemspec
54 57
      end
55 58

  
56 59
      # Directory name used as the theme id
......
148 151
    end
149 152

  
150 153
    def self.scan_themes
151
      dirs = Dir.glob(["#{Rails.root}/app/assets/themes/*", "#{Rails.root}/themes/*"]).select do |f|
152
        # A theme should at least override application.css
153
        File.directory?(f) && File.exist?("#{f}/stylesheets/application.css")
154
      theme_dirs = ['app/assets/themes', 'themes']
155
      themes = theme_dirs.map do |theme_dir|
156
        Rails.root.join(theme_dir).children.select{|dir| valid_theme?(dir)}.map{|dir| Theme.new(dir)}
154 157
      end
155
      dirs.collect {|dir| Theme.new(dir)}.sort
158
      directories = themes.flatten
159

  
160
      # If there are theme under the public dir, do not register a gem with the same name.
161
      gemspecs.each do |spec|
162
        if dir = theme_dirs.find{|theme_dir| Rails.root.join(theme_dir, spec.name).directory?}
163
          warn "WARN: \"#{spec.name}\" theme installed as gems also exist in the \"#{dir}\" directory; the theme in \"#{dir}\" is used."
164
          next
165
        end
166
        directories << Theme.new(spec.full_gem_path, spec)
167
      end
168
      directories.sort
169
    end
170

  
171
    def self.gemspecs
172
      specs = Bundler.definition
173
                     .specs_for([:redmine_extension])
174
                     .to_a
175
                     .select{|s| s.name != 'bundler' && !s.metadata['redmine_theme_id'].nil? && valid_theme?(s.full_gem_path)}
176
      duplicates = specs.group_by{|s| s.metadata['redmine_theme_id']}.reject{|k, v| v.one?}.keys
177
      raise ThemeIdDuplicated.new("#{duplicates.join(',')} Duplicate theme id") unless duplicates.empty?
178

  
179
      specs
180
    end
181

  
182
    def self.valid_theme?(dir)
183
      # A theme should at least override application.css
184
      File.directory?(dir) && File.exist?("#{dir}/stylesheets/application.css")
156 185
    end
157
    private_class_method :scan_themes
186
    private_class_method :scan_themes, :gemspecs, :valid_theme?
158 187
  end
159 188
end
test/fixtures/gem_extensions/.gitignore
1
Gemfile.lock
test/fixtures/gem_extensions/Gemfile
1
# frozen_string_literal: true
2

  
3
original_gemfile = File.join(File.dirname(__FILE__), "../../../Gemfile")
4

  
5
eval_gemfile original_gemfile
6

  
7
gem 'quux', path: './quux', require: false
8

  
9
group :redmine_extension do
10
  gem 'baz', path: './baz'
11
  gem 'foobar', path: './foobar'
12
end
test/fixtures/gem_extensions/baz/Gemfile
1
# frozen_string_literal: true
2

  
3
source "https://rubygems.org"
4

  
5
# Specify your gem's dependencies in baz_plugin.gemspec
6
gemspec
test/fixtures/gem_extensions/baz/baz.gemspec
1
# frozen_string_literal: true
2

  
3
Gem::Specification.new do |spec|
4
  spec.name = "baz"
5
  spec.version = "0.0.1"
6
  spec.authors = ['johndoe', 'janedoe']
7
  spec.email = ['johndoe@example.org']
8

  
9
  spec.summary = "Baz Plugin"
10
  spec.description = "This is a gemified plugin for Redmine"
11
  spec.homepage = "https://example.org/plugins/baz"
12

  
13
  spec.required_ruby_version = ">= 3.2.0"
14

  
15
  spec.metadata['allowed_push_host'] = "https://example.org"
16

  
17
  spec.metadata['redmine_plugin_id'] = "baz_plugin"
18
  spec.metadata['rubygems_mfa_required'] = "true"
19
  spec.files = Dir["{app,lib,config,assets,db}/**/*", "init.rb", "Gemfile", "README.rdoc"]
20

  
21
  spec.add_dependency 'quux'
22
end
test/fixtures/gem_extensions/baz/init.rb
1
# frozen_string_literal: true
2

  
3
Redmine::Plugin.register :baz_plugin do
4
  name "This name should be overwritten with gemspec 'summary'"
5
  author_url "https://example.org/this_url_should_not_be_overwritten_with_gemspec"
6
end
test/fixtures/gem_extensions/baz/lib/baz_plugin.rb
1
# frozen_string_literal: true
2

  
3
module BazPlugin
4
  class Error < StandardError; end
5
end
test/fixtures/gem_extensions/foobar/Gemfile
1
# frozen_string_literal: true
2

  
3
source "https://rubygems.org"
4

  
5
# Specify your gem's dependencies in baz_plugin.gemspec
6
gemspec
test/fixtures/gem_extensions/foobar/foobar.gemspec
1
# frozen_string_literal: true
2

  
3
Gem::Specification.new do |spec|
4
  spec.name = "foobar"
5
  spec.version = "0.0.1"
6
  spec.authors = ['johndoe', 'janedoe']
7
  spec.email = ['johndoe@example.org']
8

  
9
  spec.summary = "Foobar theme"
10
  spec.description = "This is a gemified theme for Redmine"
11
  spec.homepage = "https://example.org/themes/foobar"
12

  
13
  spec.required_ruby_version = ">= 3.2.0"
14

  
15
  spec.metadata['allowed_push_host'] = "https://example.org"
16

  
17
  spec.metadata['redmine_theme_id'] = "foobar_theme"
18
  spec.metadata['rubygems_mfa_required'] = "true"
19
  spec.files = Dir["{app,lib,config,assets,db}/**/*", "Gemfile"]
20
end
test/fixtures/gem_extensions/quux/Gemfile
1
# frozen_string_literal: true
2

  
3
source "https://rubygems.org"
4

  
5
# Specify your gem's dependencies in quux.gemspec
6
gemspec
test/fixtures/gem_extensions/quux/init.rb
1
# frozen_string_literal: true
2

  
3
Redmine::Plugin.register :quux_plugin do
4
  # For gemmed plugins, the attributes of the plugin are described in the gemspec.
5
  # The correspondence between plugin attributes and gemspec is as follows
6
  name "This name should be overwritten with gemspec 'summary'"
7
  author_url "https://example.org/this_url_should_not_be_overwritten_with_gemspec"
8
end
test/fixtures/gem_extensions/quux/lib/quux_plugin.rb
1
# frozen_string_literal: true
2

  
3
module QuuxPlugin
4
  class Error < StandardError; end
5
end
test/fixtures/gem_extensions/quux/quux.gemspec
1
# frozen_string_literal: true
2

  
3
Gem::Specification.new do |spec|
4
  # Do not use constants or variables from the gem's own code in this block, as is normally
5
  # done with gems. (e.g. Foo::VERSION)
6
  # Specify the version of redmine or dependencies between plugins in the init.rb file.
7

  
8
  spec.name = "quux"
9
  spec.version = "0.0.1"
10
  spec.authors = ["johndoe"]
11
  spec.email = ["johndoe@example.org"]
12

  
13
  spec.summary = "Quux plugin"
14
  spec.description = "This is a plugin for Redmine"
15
  spec.homepage = "https://example.org"
16
  spec.required_ruby_version = ">= 3.2.0"
17

  
18
  spec.metadata["author_url"] = spec.homepage
19
  spec.files = Dir["{lib}/**/*", "init.rb", "Gemfile"]
20

  
21
  # DO NOT DELETE this attribute
22
  spec.metadata["redmine_plugin_id"] = "quux_plugin"
23

  
24
  spec.metadata['rubygems_mfa_required'] = 'true'
25
end
test/fixtures/invalid_plugins/qux_plugin/init.rb
1
# frozen_string_literal: true
2

  
3
Redmine::Plugin.register :baz_plugin do
4
  name "This name should be overwritten with gemspec 'summary'"
5
end
test/unit/lib/redmine/plugin_test.rb
37 37
    @klass.clear
38 38
  end
39 39

  
40
  def gemfile_for_test?
41
    File.expand_path(ENV['BUNDLE_GEMFILE']) == Rails.root.join('test/fixtures/gem_extensions/Gemfile').expand_path.to_s
42
  end
43

  
40 44
  def test_register
41 45
    @klass.register :foo_plugin do
42 46
      name 'Foo plugin'
......
70 74
    end
71 75
  end
72 76

  
77
  def test_gemified_plugin
78
    skip unless gemfile_for_test?
79
    path = Redmine::PluginLoader.find_path(plugin_id: "baz_plugin", plugin_dir: nil)
80
    path.run_initializer
81
    plugin = @klass.find('baz_plugin')
82
    assert_equal :baz_plugin, plugin.id
83
    assert_equal 'Baz Plugin', plugin.name
84
    assert_equal 'https://example.org/plugins/baz', plugin.url
85
    assert_equal 'johndoe,janedoe', plugin.author
86
    assert_equal 'https://example.org/this_url_should_not_be_overwritten_with_gemspec', plugin.author_url
87
    assert_equal 'This is a gemified plugin for Redmine', plugin.description
88
    assert_equal '0.0.1', plugin.version
89
  end
90

  
91
  def test_gemified_plugin_depended_by_other_plugin
92
    skip unless gemfile_for_test?
93
    path = Redmine::PluginLoader.find_path(plugin_id: "quux_plugin", plugin_dir: nil)
94
    path.run_initializer
95
    plugin = @klass.find('quux_plugin')
96
    assert_equal :quux_plugin, plugin.id
97
  end
98

  
99
  def test_invalid_gemified_plugin
100
    skip unless gemfile_for_test?
101

  
102
    @klass.directory = Rails.root.join('test/fixtures/invalid_plugins')
103
    @klass.clear
104
    Redmine::PluginLoader.directory = @klass.directory
105
    Redmine::PluginLoader.setup
106

  
107
    plugin_dir = File.join(@klass.directory, 'qux_plugin')
108
    path = Redmine::PluginLoader.find_path(plugin_id: nil, plugin_dir: plugin_dir)
109
    e = assert_raises Redmine::InvalidPluginId do
110
      path.run_initializer
111
    end
112
    assert_equal "The location of init.rb is different from #{Rails.root.join('test/fixtures/gem_extensions/baz')}. It called from #{path.initializer}", e.message
113
  end
114

  
73 115
  def test_register_should_raise_error_if_plugin_directory_does_not_exist
74 116
    e = assert_raises Redmine::PluginNotFound do
75 117
      @klass.register(:bar_plugin) {}
test/unit/lib/redmine/themes_test.rb
20 20
require_relative '../../../test_helper'
21 21

  
22 22
class Redmine::ThemesTest < ActiveSupport::TestCase
23
  def gemfile_for_test?
24
    File.expand_path(ENV['BUNDLE_GEMFILE']) == Rails.root.join('test/fixtures/gem_extensions/Gemfile').expand_path.to_s
25
  end
26

  
27
  def test_gemified_theme
28
    skip unless gemfile_for_test?
29
    themes = Redmine::Themes.themes
30
    theme = themes.find {|t| t.id == 'foobar'}
31
    gemspec = theme.gemspec
32
    assert_equal 'Foobar', theme.name
33
    assert_equal 'foobar', gemspec.name
34
    assert_equal '0.0.1', gemspec.version.to_s
35
  end
36

  
23 37
  def test_themes
24 38
    themes = Redmine::Themes.themes
25 39
    assert_kind_of Array, themes
(7-7/8)