redmine_plugin_kit 1.0.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94857a34d940b2fccba2f613e3299cef59acc88c20c3279671831fd923c3e28e
4
- data.tar.gz: 4d6e9c22ea18b5050b9084fe60b577a4cb3da032801a13a0b16f6ec99664a443
3
+ metadata.gz: df099201258470b440ad75ae0fb6631ebcee98e347346b01be981a13fa26c0af
4
+ data.tar.gz: db29596ff3a4029ff11871fce14b8c4c0f6d8f5ab930762802854ac798bdc3d3
5
5
  SHA512:
6
- metadata.gz: 90fe0d769fa01295f8dea757cff3aa5f9dc48005c8ee4ea356b6cdece1da7e0330f11020359cee3f9cb53c587eaa95281c7b7d1493d4a95be8cad1e7dbe26ad9
7
- data.tar.gz: f3d7a85ab307ffe4425229878f41581e03a04a833e34f626e8336ae52005e6f2a04be166517d6fcdb6b32489ceda1d08ba2d58ebef77f3265547c71a9ebf605f
6
+ metadata.gz: 8bb91988a44e45448e2baa3e8c920780bbd6a6ffd27e7a2dce4d3d73e81d296d54fe5f54761a3a4924337e5d8cde99d2f73782c9162f70811a7e17f0a77e16f0
7
+ data.tar.gz: ba3c2b788544d8fc3363632b6414d9885f147ae6ae641eb94c47fd3a415291725f857e0171557a6143b49e84df4014fa86493166f175c3186dd4a94d46ea55da
data/README.md CHANGED
@@ -10,6 +10,7 @@ If you are a Redmine developer, feel free and offer PRs with your Improvements.
10
10
 
11
11
  - Loader for plugin customizations
12
12
  - Deface support for Redmine plugins
13
+ - Extension points: classes a plugin collects from all plugins, without a list naming them
13
14
 
14
15
  ## Why this gem?
15
16
 
@@ -19,7 +20,7 @@ If you are a Redmine developer, feel free and offer PRs with your Improvements.
19
20
  ## Requirements
20
21
 
21
22
  - Redmine `>= 4.1.0` (including upcoming)
22
- - Ruby `>= 2.7` (only maintained ruby versions are supported)
23
+ - Ruby `>= 3.2` (only maintained ruby versions are supported)
23
24
 
24
25
  ## Installation
25
26
 
@@ -28,3 +29,49 @@ Add Gem to your Gemfile:
28
29
  ```ruby
29
30
  gem 'redmine_plugin_kit'
30
31
  ```
32
+
33
+ ## Extension points
34
+
35
+ A plugin (the host) can collect classes from all plugins, one file per class, e.g. widgets of `my_host`:
36
+
37
+ - every plugin keeps its classes in `lib/widgets/`; the host also has the module `lib/widgets.rb`, which makes it the host
38
+ - any other plugin declares the point in its `init.rb`. Zeitwerk then never eager loads its `lib/widgets/`, only `load!` of the host loads it, so without the host these files need no guard against a missing base class. They stay autoloadable: nothing outside `lib/widgets/` may refer to their constants, and `defined?(Widgets)` is no test for the host.
39
+
40
+ ```ruby
41
+ loader = RedminePluginKit::Loader.new plugin_id: 'my_plugin'
42
+ loader.extends %w[widgets]
43
+
44
+ # a plugin without RedminePluginKit::Loader
45
+ RedminePluginKit::Extensions.declare __dir__, %w[widgets]
46
+ ```
47
+
48
+ `classes` returns only what `load!` loaded, so read them at runtime, not in the setup of a plugin (the setups run in plugin order); `RedminePluginKit::Extensions.loaded? 'widgets'` tells whether `load!` ran.
49
+
50
+ An undeclared `lib/widgets/` stops the boot as long as the host is installed. Without it, a convention test of the plugin finds such a directory:
51
+
52
+ ```ruby
53
+ assert_empty RedminePluginKit::Extensions.undeclared_dirs(plugin_dir, standalone: %w[searches])
54
+ ```
55
+
56
+ `standalone` names the directories in `lib/` with classes but no module that belong to the plugin itself; a name without such a directory is an error.
57
+
58
+ The host loads and lists them:
59
+
60
+ ```ruby
61
+ module Widgets
62
+ class Base
63
+ extend RedminePluginKit::Extensions::Abstract
64
+
65
+ abstract!
66
+ end
67
+ end
68
+
69
+ # in the setup of the host, which runs at boot and after every code reload
70
+ RedminePluginKit::Extensions.load! 'widgets'
71
+
72
+ # the loaded subclasses without abstract ones; with a key, a subclass with the
73
+ # same key replaces its superclass and two unrelated classes are an error
74
+ RedminePluginKit::Extensions.classes Widgets::Base, key: :widget_name
75
+ ```
76
+
77
+ A file name used by several plugins stops the boot, as Zeitwerk would load only one of them.
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedminePluginKit
4
+ # Classes one plugin (the host) collects from all plugins, a file per class
5
+ # and no list naming them, e.g. dashboard blocks:
6
+ #
7
+ # - every plugin keeps them in lib/<point>/; the host also has the module
8
+ # lib/<point>.rb, which makes it the host
9
+ # - any other plugin declares the point in its init.rb with
10
+ # loader.extends %w[<point>]. Zeitwerk then never eager loads its
11
+ # lib/<point>/, only load! of the host loads it, so without the host the
12
+ # files need no guard against a missing base class. They stay autoloadable
13
+ # though: nothing outside lib/<point>/ may refer to their constants, and
14
+ # defined?(<Point>) is no test for the host.
15
+ #
16
+ # The host loads them with load! and gets them with classes, which reads
17
+ # only what load! loaded: read them at runtime, not in the setup of a plugin,
18
+ # as the setups run in plugin order.
19
+ module Extensions
20
+ # Zeitwerk loads only one of two files of the same name in different
21
+ # plugins and drops the other one silently
22
+ class DuplicateError < StandardError; end
23
+
24
+ # Two classes with the same key, neither a subclass of the other
25
+ class ConflictError < StandardError; end
26
+
27
+ # A plugin has lib/<point>/ of another plugin without declaring it: without
28
+ # that plugin installed, its files would fail on the missing base class
29
+ class UndeclaredError < StandardError; end
30
+
31
+ # For the base classes of a point, which are no extension themselves
32
+ module Abstract
33
+ def abstract!
34
+ @abstract = true
35
+ end
36
+
37
+ # Not inherited: a subclass of a base class is an extension
38
+ def abstract?
39
+ @abstract == true
40
+ end
41
+ end
42
+
43
+ class << self
44
+ # Called from init.rb (see Loader#extends). Redmine runs it in
45
+ # to_prepare, after Zeitwerk is set up, so ignoring is too late there,
46
+ # but it runs before eager loading.
47
+ def declare(plugin_dir, points, loader: Rails.autoloaders.main, declared: declarations)
48
+ plugin_id = File.basename plugin_dir.to_s
49
+ points = Array(points).map(&:to_s)
50
+ declared[plugin_id] = declared.fetch(plugin_id, []) | points
51
+
52
+ points.each { |point| loader.do_not_eager_load File.join(plugin_dir.to_s, 'lib', point) }
53
+ end
54
+
55
+ # Points by plugin id
56
+ def declarations
57
+ @declarations ||= {}
58
+ end
59
+
60
+ # For a convention test of a plugin: directories in lib/ with classes but
61
+ # no module lib/<name>.rb, which are neither declared nor named as
62
+ # standalone. Unlike the check at boot, it needs no host installed.
63
+ def undeclared_dirs(plugin_dir, standalone: [], declared: declarations)
64
+ plugin_dir = plugin_dir.to_s
65
+ lib = File.join plugin_dir, 'lib'
66
+ candidates = File.directory?(lib) ? Dir.children(lib).select { |name| guest_dir? plugin_dir, name } : []
67
+ standalone = standalone.map(&:to_s)
68
+ points = declared.fetch File.basename(plugin_dir), []
69
+
70
+ # A stale entry would hide a directory added later under its name
71
+ check_standalone! standalone - candidates, 'names no directory with classes and without a module'
72
+ check_standalone! standalone & points, 'names a point declared with loader.extends'
73
+
74
+ (candidates - points - standalone).sort
75
+ end
76
+
77
+ # Loads the classes of all plugins; call it from the setup of the host,
78
+ # which runs at boot and after every code reload
79
+ # Declared directories are excluded from eager loading, so each file is
80
+ # loaded on its own (load_file honours no exclusion, unlike
81
+ # eager_load_dir, which would skip them silently).
82
+ def load!(point, plugin_dirs: redmine_plugin_dirs, loader: Rails.autoloaders.main, declared: declarations)
83
+ check_declared! point, plugin_dirs, declared
84
+ dirs = directories point, plugin_dirs: plugin_dirs
85
+ check_duplicates! dirs
86
+ dirs.each do |dir|
87
+ Dir.glob('**/*.rb', base: dir).each { |file| loader.load_file File.join(dir, file) }
88
+ end
89
+ loaded_points << point.to_s
90
+ end
91
+
92
+ # Whether load! ran for the point; until then classes would miss the
93
+ # extensions of all plugins
94
+ def loaded?(point)
95
+ loaded_points.include? point.to_s
96
+ end
97
+
98
+ # Runs from load!, after the init.rb of all plugins
99
+ def check_declared!(point, plugin_dirs, declared)
100
+ undeclared = plugin_dirs.select { |dir| guest_dir?(dir, point) && declared.fetch(File.basename(dir), []).exclude?(point) }
101
+ return if undeclared.empty?
102
+
103
+ raise UndeclaredError,
104
+ "lib/#{point} of #{undeclared.map { |dir| File.basename dir }.join ', '} is not declared, " \
105
+ "add to init.rb: loader.extends %w[#{point}]"
106
+ end
107
+
108
+ def directories(point, plugin_dirs: redmine_plugin_dirs)
109
+ candidates = plugin_dirs.map { |plugin_dir| File.join plugin_dir, 'lib', point }
110
+ candidates.select! { |dir| File.directory? dir }
111
+ candidates
112
+ end
113
+
114
+ # Directories of the same name are fine, Zeitwerk merges them
115
+ def check_duplicates!(dirs)
116
+ duplicates = dirs.flat_map { |dir| Dir.glob '**/*.rb', base: dir }.tally.select { |_file, count| count > 1 }.keys
117
+ raise DuplicateError, "In several plugins: #{duplicates.join ', '}" if duplicates.any?
118
+ end
119
+
120
+ # The loaded subclasses of a base, without base classes and anonymous
121
+ # classes (e.g. of tests). With a key (e.g. :block_id) classes with the
122
+ # same key are one extension: a subclass replaces its superclass, so a
123
+ # plugin can change an extension of another one.
124
+ def classes(base, key: nil)
125
+ found = base.descendants.reject { |klass| klass.abstract? || klass.name.nil? }
126
+ return found unless key
127
+
128
+ found.group_by(&key).map { |value, same_key| most_specific value, same_key }
129
+ end
130
+
131
+ private
132
+
133
+ def loaded_points
134
+ @loaded_points ||= Set.new
135
+ end
136
+
137
+ def check_standalone!(names, problem)
138
+ raise ArgumentError, "standalone #{problem}: #{names.join ', '}" if names.any?
139
+ end
140
+
141
+ # lib/<name>/ with classes, but without the module of a host
142
+ def guest_dir?(plugin_dir, name)
143
+ dir = File.join plugin_dir, 'lib', name
144
+ File.directory?(dir) && !File.file?("#{dir}.rb") && Dir.glob('**/*.rb', base: dir).any?
145
+ end
146
+
147
+ def most_specific(value, classes)
148
+ found = classes.detect { |candidate| classes.all? { |other| candidate <= other } }
149
+ raise ConflictError, "#{value}: #{classes.map(&:name).join ', '}" unless found
150
+
151
+ found
152
+ end
153
+
154
+ def redmine_plugin_dirs
155
+ Redmine::PluginLoader.directories.map(&:to_s)
156
+ end
157
+ end
158
+ end
159
+ end
@@ -11,7 +11,7 @@ module RedminePluginKit
11
11
  link_to name, link, **options
12
12
  end
13
13
 
14
- def link_to_url(url, **options)
14
+ def link_to_url(url, **)
15
15
  return if url.blank?
16
16
 
17
17
  parts = url.split '://'
@@ -22,7 +22,7 @@ module RedminePluginKit
22
22
  url.chomp '/'
23
23
  end
24
24
 
25
- link_to_external name, url, **options
25
+ link_to_external(name, url, **)
26
26
  end
27
27
  end
28
28
  end
@@ -21,8 +21,8 @@ module RedminePluginKit
21
21
  yield
22
22
  end
23
23
 
24
- def after_initialize(&block)
25
- Rails.application.config.after_initialize(&block)
24
+ def after_initialize(&)
25
+ Rails.application.config.after_initialize(&)
26
26
  end
27
27
 
28
28
  # required multiple times because of this bug: https://www.redmine.org/issues/33290
@@ -49,6 +49,12 @@ module RedminePluginKit
49
49
  apply_reset
50
50
  end
51
51
 
52
+ # lib/<point>/ of this plugin holds classes another plugin collects, see
53
+ # Extensions. Call it in init.rb.
54
+ def extends(points)
55
+ Extensions.declare plugin_dir, points
56
+ end
57
+
52
58
  def default_settings
53
59
  cached_settings_name = "@default_settings_#{plugin_id}"
54
60
  cached_settings = instance_variable_get cached_settings_name
@@ -85,7 +91,7 @@ module RedminePluginKit
85
91
  File.join plugin_dir, 'lib', plugin_id, spec
86
92
  end
87
93
 
88
- files = Dir[dir].sort
94
+ files = Dir[dir]
89
95
 
90
96
  files.reverse! if reverse
91
97
  files.each do |f|
@@ -48,10 +48,9 @@ module RedminePluginKit
48
48
  # requires_redmine_plugin runs while init.rb is loaded (during alphabetical
49
49
  # registration) and therefore only works for dependencies that load earlier,
50
50
  # such as additionals / additional_tags. A dependency that loads later (e.g.
51
- # redmine_automation depending on redmine_reporting) would raise
51
+ # plugin_a depending on plugin_b) would raise
52
52
  # PluginNotFound even though it is installed. To see a plugin's hard
53
53
  # AlphaNodes dependencies, look at its REQUIRED_ALPHANODES_PLUGINS constant.
54
- # rubocop: disable Style/RaiseArgs
55
54
  def setup_required_plugins
56
55
  return unless defined? self::REQUIRED_ALPHANODES_PLUGINS
57
56
  raise 'VERSION missing for REQUIRED_ALPHANODES_PLUGINS' unless defined? self::VERSION
@@ -59,15 +58,14 @@ module RedminePluginKit
59
58
  self::REQUIRED_ALPHANODES_PLUGINS.each do |required_plugin|
60
59
  plugin = Redmine::Plugin.find required_plugin
61
60
  unless self::VERSION.include? plugin.version
62
- raise Redmine::PluginRequirementError.new "#{plugin_id} plugin requires #{required_plugin} plugin version #{self::VERSION}"
61
+ raise Redmine::PluginRequirementError, "#{plugin_id} plugin requires #{required_plugin} plugin version #{self::VERSION}"
63
62
  end
64
63
  rescue Redmine::PluginNotFound
65
- raise Redmine::PluginRequirementError.new "#{plugin_id} plugin requires the #{required_plugin} plugin." \
66
- "Please install #{required_plugin} plugin (https://alphanodes.com/#{required_plugin.tr '-',
67
- '_'})"
64
+ raise Redmine::PluginRequirementError,
65
+ "#{plugin_id} plugin requires the #{required_plugin} plugin. " \
66
+ "Please install #{required_plugin} plugin (https://alphanodes.com/#{required_plugin.tr '-', '_'})"
68
67
  end
69
68
  end
70
- # rubocop: enable Style/RaiseArgs
71
69
 
72
70
  def init_loader(existing_loader)
73
71
  @loader = existing_loader
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RedminePluginKit
4
- VERSION = '1.0.9'
4
+ VERSION = '1.1.0'
5
5
  end
@@ -4,6 +4,7 @@ require 'deface'
4
4
 
5
5
  require 'redmine_plugin_kit/version'
6
6
  require 'redmine_plugin_kit/debug'
7
+ require 'redmine_plugin_kit/extensions'
7
8
  require 'redmine_plugin_kit/engine'
8
9
  require 'redmine_plugin_kit/plugin_base'
9
10
  require 'redmine_plugin_kit/loader'
@@ -26,6 +27,17 @@ module RedminePluginKit
26
27
  !true?(value)
27
28
  end
28
29
 
30
+ # Returns plugin settings with indifferent access and the given setting applied.
31
+ # Redmine stores plugin settings from the settings form this way, too. Without it,
32
+ # a string key from the command line would be added next to an existing symbol key,
33
+ # and plugins reading the symbol key would never see the new value.
34
+ def plugin_settings_with(settings, setting, value)
35
+ settings = {} unless settings.is_a? Hash
36
+ settings = settings.with_indifferent_access
37
+ settings[setting] = value
38
+ settings
39
+ end
40
+
29
41
  def textarea_cols(text, min: 8, max: 20)
30
42
  [[min, text.to_s.length / 50].max, max].min # rubocop: disable Style/ComparableClamp
31
43
  end
@@ -46,9 +46,7 @@ namespace :redmine_plugin_kit do
46
46
  Setting[setting.to_sym] = value
47
47
  else
48
48
  plugin_name = :"plugin_#{name}"
49
- plugin_settings = Setting[plugin_name]
50
- plugin_settings[setting] = value
51
- Setting[plugin_name] = plugin_settings
49
+ Setting[plugin_name] = RedminePluginKit.plugin_settings_with Setting[plugin_name], setting, value
52
50
  end
53
51
  end
54
52
 
@@ -76,7 +74,7 @@ namespace :redmine_plugin_kit do
76
74
  else
77
75
  plugin_name = :"plugin_#{name}"
78
76
  plugin_settings = Setting[plugin_name]
79
- puts plugin_settings[setting]
77
+ puts plugin_settings.with_indifferent_access[setting] if plugin_settings.is_a? Hash
80
78
  end
81
79
  end
82
80
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redmine_plugin_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.9
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - AlphaNodes
@@ -50,6 +50,7 @@ files:
50
50
  - lib/redmine_plugin_kit.rb
51
51
  - lib/redmine_plugin_kit/debug.rb
52
52
  - lib/redmine_plugin_kit/engine.rb
53
+ - lib/redmine_plugin_kit/extensions.rb
53
54
  - lib/redmine_plugin_kit/helpers/global_helper.rb
54
55
  - lib/redmine_plugin_kit/loader.rb
55
56
  - lib/redmine_plugin_kit/plugin_base.rb
@@ -67,7 +68,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
67
68
  requirements:
68
69
  - - ">="
69
70
  - !ruby/object:Gem::Version
70
- version: '2.7'
71
+ version: 3.2.0
71
72
  required_rubygems_version: !ruby/object:Gem::Requirement
72
73
  requirements:
73
74
  - - ">="