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 +4 -4
- data/README.md +48 -1
- data/lib/redmine_plugin_kit/extensions.rb +159 -0
- data/lib/redmine_plugin_kit/helpers/global_helper.rb +2 -2
- data/lib/redmine_plugin_kit/loader.rb +9 -3
- data/lib/redmine_plugin_kit/plugin_base.rb +5 -7
- data/lib/redmine_plugin_kit/version.rb +1 -1
- data/lib/redmine_plugin_kit.rb +12 -0
- data/lib/tasks/redmine_plugin_kit.rake +2 -4
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: df099201258470b440ad75ae0fb6631ebcee98e347346b01be981a13fa26c0af
|
|
4
|
+
data.tar.gz: db29596ff3a4029ff11871fce14b8c4c0f6d8f5ab930762802854ac798bdc3d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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, **
|
|
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
|
|
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(&
|
|
25
|
-
Rails.application.config.after_initialize(&
|
|
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]
|
|
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
|
-
#
|
|
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
|
|
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
|
|
66
|
-
|
|
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
|
data/lib/redmine_plugin_kit.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
71
|
+
version: 3.2.0
|
|
71
72
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
72
73
|
requirements:
|
|
73
74
|
- - ">="
|