##// END OF EJS Templates
Updates #wiki_format_provider plugin API with changes to wiki formatting (#20141)....
Jean-Philippe Lang -
r13981:0a6f936626f4
parent child
Show More
@@ -1,497 +1,504
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 module Redmine #:nodoc:
18 module Redmine #:nodoc:
19
19
20 class PluginNotFound < StandardError; end
20 class PluginNotFound < StandardError; end
21 class PluginRequirementError < StandardError; end
21 class PluginRequirementError < StandardError; end
22
22
23 # Base class for Redmine plugins.
23 # Base class for Redmine plugins.
24 # Plugins are registered using the <tt>register</tt> class method that acts as the public constructor.
24 # Plugins are registered using the <tt>register</tt> class method that acts as the public constructor.
25 #
25 #
26 # Redmine::Plugin.register :example do
26 # Redmine::Plugin.register :example do
27 # name 'Example plugin'
27 # name 'Example plugin'
28 # author 'John Smith'
28 # author 'John Smith'
29 # description 'This is an example plugin for Redmine'
29 # description 'This is an example plugin for Redmine'
30 # version '0.0.1'
30 # version '0.0.1'
31 # settings :default => {'foo'=>'bar'}, :partial => 'settings/settings'
31 # settings :default => {'foo'=>'bar'}, :partial => 'settings/settings'
32 # end
32 # end
33 #
33 #
34 # === Plugin attributes
34 # === Plugin attributes
35 #
35 #
36 # +settings+ is an optional attribute that let the plugin be configurable.
36 # +settings+ is an optional attribute that let the plugin be configurable.
37 # It must be a hash with the following keys:
37 # It must be a hash with the following keys:
38 # * <tt>:default</tt>: default value for the plugin settings
38 # * <tt>:default</tt>: default value for the plugin settings
39 # * <tt>:partial</tt>: path of the configuration partial view, relative to the plugin <tt>app/views</tt> directory
39 # * <tt>:partial</tt>: path of the configuration partial view, relative to the plugin <tt>app/views</tt> directory
40 # Example:
40 # Example:
41 # settings :default => {'foo'=>'bar'}, :partial => 'settings/settings'
41 # settings :default => {'foo'=>'bar'}, :partial => 'settings/settings'
42 # In this example, the settings partial will be found here in the plugin directory: <tt>app/views/settings/_settings.rhtml</tt>.
42 # In this example, the settings partial will be found here in the plugin directory: <tt>app/views/settings/_settings.rhtml</tt>.
43 #
43 #
44 # When rendered, the plugin settings value is available as the local variable +settings+
44 # When rendered, the plugin settings value is available as the local variable +settings+
45 class Plugin
45 class Plugin
46 cattr_accessor :directory
46 cattr_accessor :directory
47 self.directory = File.join(Rails.root, 'plugins')
47 self.directory = File.join(Rails.root, 'plugins')
48
48
49 cattr_accessor :public_directory
49 cattr_accessor :public_directory
50 self.public_directory = File.join(Rails.root, 'public', 'plugin_assets')
50 self.public_directory = File.join(Rails.root, 'public', 'plugin_assets')
51
51
52 @registered_plugins = {}
52 @registered_plugins = {}
53 @used_partials = {}
53 @used_partials = {}
54
54
55 class << self
55 class << self
56 attr_reader :registered_plugins
56 attr_reader :registered_plugins
57 private :new
57 private :new
58
58
59 def def_field(*names)
59 def def_field(*names)
60 class_eval do
60 class_eval do
61 names.each do |name|
61 names.each do |name|
62 define_method(name) do |*args|
62 define_method(name) do |*args|
63 args.empty? ? instance_variable_get("@#{name}") : instance_variable_set("@#{name}", *args)
63 args.empty? ? instance_variable_get("@#{name}") : instance_variable_set("@#{name}", *args)
64 end
64 end
65 end
65 end
66 end
66 end
67 end
67 end
68 end
68 end
69 def_field :name, :description, :url, :author, :author_url, :version, :settings, :directory
69 def_field :name, :description, :url, :author, :author_url, :version, :settings, :directory
70 attr_reader :id
70 attr_reader :id
71
71
72 # Plugin constructor
72 # Plugin constructor
73 def self.register(id, &block)
73 def self.register(id, &block)
74 p = new(id)
74 p = new(id)
75 p.instance_eval(&block)
75 p.instance_eval(&block)
76
76
77 # Set a default name if it was not provided during registration
77 # Set a default name if it was not provided during registration
78 p.name(id.to_s.humanize) if p.name.nil?
78 p.name(id.to_s.humanize) if p.name.nil?
79 # Set a default directory if it was not provided during registration
79 # Set a default directory if it was not provided during registration
80 p.directory(File.join(self.directory, id.to_s)) if p.directory.nil?
80 p.directory(File.join(self.directory, id.to_s)) if p.directory.nil?
81
81
82 # Adds plugin locales if any
82 # Adds plugin locales if any
83 # YAML translation files should be found under <plugin>/config/locales/
83 # YAML translation files should be found under <plugin>/config/locales/
84 Rails.application.config.i18n.load_path += Dir.glob(File.join(p.directory, 'config', 'locales', '*.yml'))
84 Rails.application.config.i18n.load_path += Dir.glob(File.join(p.directory, 'config', 'locales', '*.yml'))
85
85
86 # Prepends the app/views directory of the plugin to the view path
86 # Prepends the app/views directory of the plugin to the view path
87 view_path = File.join(p.directory, 'app', 'views')
87 view_path = File.join(p.directory, 'app', 'views')
88 if File.directory?(view_path)
88 if File.directory?(view_path)
89 ActionController::Base.prepend_view_path(view_path)
89 ActionController::Base.prepend_view_path(view_path)
90 ActionMailer::Base.prepend_view_path(view_path)
90 ActionMailer::Base.prepend_view_path(view_path)
91 end
91 end
92
92
93 # Adds the app/{controllers,helpers,models} directories of the plugin to the autoload path
93 # Adds the app/{controllers,helpers,models} directories of the plugin to the autoload path
94 Dir.glob File.expand_path(File.join(p.directory, 'app', '{controllers,helpers,models}')) do |dir|
94 Dir.glob File.expand_path(File.join(p.directory, 'app', '{controllers,helpers,models}')) do |dir|
95 ActiveSupport::Dependencies.autoload_paths += [dir]
95 ActiveSupport::Dependencies.autoload_paths += [dir]
96 end
96 end
97
97
98 # Defines plugin setting if present
98 # Defines plugin setting if present
99 if p.settings
99 if p.settings
100 Setting.define_plugin_setting p
100 Setting.define_plugin_setting p
101 end
101 end
102
102
103 # Warn for potential settings[:partial] collisions
103 # Warn for potential settings[:partial] collisions
104 if p.configurable?
104 if p.configurable?
105 partial = p.settings[:partial]
105 partial = p.settings[:partial]
106 if @used_partials[partial]
106 if @used_partials[partial]
107 Rails.logger.warn "WARNING: settings partial '#{partial}' is declared in '#{p.id}' plugin but it is already used by plugin '#{@used_partials[partial]}'. Only one settings view will be used. You may want to contact those plugins authors to fix this."
107 Rails.logger.warn "WARNING: settings partial '#{partial}' is declared in '#{p.id}' plugin but it is already used by plugin '#{@used_partials[partial]}'. Only one settings view will be used. You may want to contact those plugins authors to fix this."
108 end
108 end
109 @used_partials[partial] = p.id
109 @used_partials[partial] = p.id
110 end
110 end
111
111
112 registered_plugins[id] = p
112 registered_plugins[id] = p
113 end
113 end
114
114
115 # Returns an array of all registered plugins
115 # Returns an array of all registered plugins
116 def self.all
116 def self.all
117 registered_plugins.values.sort
117 registered_plugins.values.sort
118 end
118 end
119
119
120 # Finds a plugin by its id
120 # Finds a plugin by its id
121 # Returns a PluginNotFound exception if the plugin doesn't exist
121 # Returns a PluginNotFound exception if the plugin doesn't exist
122 def self.find(id)
122 def self.find(id)
123 registered_plugins[id.to_sym] || raise(PluginNotFound)
123 registered_plugins[id.to_sym] || raise(PluginNotFound)
124 end
124 end
125
125
126 # Clears the registered plugins hash
126 # Clears the registered plugins hash
127 # It doesn't unload installed plugins
127 # It doesn't unload installed plugins
128 def self.clear
128 def self.clear
129 @registered_plugins = {}
129 @registered_plugins = {}
130 end
130 end
131
131
132 # Removes a plugin from the registered plugins
132 # Removes a plugin from the registered plugins
133 # It doesn't unload the plugin
133 # It doesn't unload the plugin
134 def self.unregister(id)
134 def self.unregister(id)
135 @registered_plugins.delete(id)
135 @registered_plugins.delete(id)
136 end
136 end
137
137
138 # Checks if a plugin is installed
138 # Checks if a plugin is installed
139 #
139 #
140 # @param [String] id name of the plugin
140 # @param [String] id name of the plugin
141 def self.installed?(id)
141 def self.installed?(id)
142 registered_plugins[id.to_sym].present?
142 registered_plugins[id.to_sym].present?
143 end
143 end
144
144
145 def self.load
145 def self.load
146 Dir.glob(File.join(self.directory, '*')).sort.each do |directory|
146 Dir.glob(File.join(self.directory, '*')).sort.each do |directory|
147 if File.directory?(directory)
147 if File.directory?(directory)
148 lib = File.join(directory, "lib")
148 lib = File.join(directory, "lib")
149 if File.directory?(lib)
149 if File.directory?(lib)
150 $:.unshift lib
150 $:.unshift lib
151 ActiveSupport::Dependencies.autoload_paths += [lib]
151 ActiveSupport::Dependencies.autoload_paths += [lib]
152 end
152 end
153 initializer = File.join(directory, "init.rb")
153 initializer = File.join(directory, "init.rb")
154 if File.file?(initializer)
154 if File.file?(initializer)
155 require initializer
155 require initializer
156 end
156 end
157 end
157 end
158 end
158 end
159 end
159 end
160
160
161 def initialize(id)
161 def initialize(id)
162 @id = id.to_sym
162 @id = id.to_sym
163 end
163 end
164
164
165 def public_directory
165 def public_directory
166 File.join(self.class.public_directory, id.to_s)
166 File.join(self.class.public_directory, id.to_s)
167 end
167 end
168
168
169 def to_param
169 def to_param
170 id
170 id
171 end
171 end
172
172
173 def assets_directory
173 def assets_directory
174 File.join(directory, 'assets')
174 File.join(directory, 'assets')
175 end
175 end
176
176
177 def <=>(plugin)
177 def <=>(plugin)
178 self.id.to_s <=> plugin.id.to_s
178 self.id.to_s <=> plugin.id.to_s
179 end
179 end
180
180
181 # Sets a requirement on Redmine version
181 # Sets a requirement on Redmine version
182 # Raises a PluginRequirementError exception if the requirement is not met
182 # Raises a PluginRequirementError exception if the requirement is not met
183 #
183 #
184 # Examples
184 # Examples
185 # # Requires Redmine 0.7.3 or higher
185 # # Requires Redmine 0.7.3 or higher
186 # requires_redmine :version_or_higher => '0.7.3'
186 # requires_redmine :version_or_higher => '0.7.3'
187 # requires_redmine '0.7.3'
187 # requires_redmine '0.7.3'
188 #
188 #
189 # # Requires Redmine 0.7.x or higher
189 # # Requires Redmine 0.7.x or higher
190 # requires_redmine '0.7'
190 # requires_redmine '0.7'
191 #
191 #
192 # # Requires a specific Redmine version
192 # # Requires a specific Redmine version
193 # requires_redmine :version => '0.7.3' # 0.7.3 only
193 # requires_redmine :version => '0.7.3' # 0.7.3 only
194 # requires_redmine :version => '0.7' # 0.7.x
194 # requires_redmine :version => '0.7' # 0.7.x
195 # requires_redmine :version => ['0.7.3', '0.8.0'] # 0.7.3 or 0.8.0
195 # requires_redmine :version => ['0.7.3', '0.8.0'] # 0.7.3 or 0.8.0
196 #
196 #
197 # # Requires a Redmine version within a range
197 # # Requires a Redmine version within a range
198 # requires_redmine :version => '0.7.3'..'0.9.1' # >= 0.7.3 and <= 0.9.1
198 # requires_redmine :version => '0.7.3'..'0.9.1' # >= 0.7.3 and <= 0.9.1
199 # requires_redmine :version => '0.7'..'0.9' # >= 0.7.x and <= 0.9.x
199 # requires_redmine :version => '0.7'..'0.9' # >= 0.7.x and <= 0.9.x
200 def requires_redmine(arg)
200 def requires_redmine(arg)
201 arg = { :version_or_higher => arg } unless arg.is_a?(Hash)
201 arg = { :version_or_higher => arg } unless arg.is_a?(Hash)
202 arg.assert_valid_keys(:version, :version_or_higher)
202 arg.assert_valid_keys(:version, :version_or_higher)
203
203
204 current = Redmine::VERSION.to_a
204 current = Redmine::VERSION.to_a
205 arg.each do |k, req|
205 arg.each do |k, req|
206 case k
206 case k
207 when :version_or_higher
207 when :version_or_higher
208 raise ArgumentError.new(":version_or_higher accepts a version string only") unless req.is_a?(String)
208 raise ArgumentError.new(":version_or_higher accepts a version string only") unless req.is_a?(String)
209 unless compare_versions(req, current) <= 0
209 unless compare_versions(req, current) <= 0
210 raise PluginRequirementError.new("#{id} plugin requires Redmine #{req} or higher but current is #{current.join('.')}")
210 raise PluginRequirementError.new("#{id} plugin requires Redmine #{req} or higher but current is #{current.join('.')}")
211 end
211 end
212 when :version
212 when :version
213 req = [req] if req.is_a?(String)
213 req = [req] if req.is_a?(String)
214 if req.is_a?(Array)
214 if req.is_a?(Array)
215 unless req.detect {|ver| compare_versions(ver, current) == 0}
215 unless req.detect {|ver| compare_versions(ver, current) == 0}
216 raise PluginRequirementError.new("#{id} plugin requires one the following Redmine versions: #{req.join(', ')} but current is #{current.join('.')}")
216 raise PluginRequirementError.new("#{id} plugin requires one the following Redmine versions: #{req.join(', ')} but current is #{current.join('.')}")
217 end
217 end
218 elsif req.is_a?(Range)
218 elsif req.is_a?(Range)
219 unless compare_versions(req.first, current) <= 0 && compare_versions(req.last, current) >= 0
219 unless compare_versions(req.first, current) <= 0 && compare_versions(req.last, current) >= 0
220 raise PluginRequirementError.new("#{id} plugin requires a Redmine version between #{req.first} and #{req.last} but current is #{current.join('.')}")
220 raise PluginRequirementError.new("#{id} plugin requires a Redmine version between #{req.first} and #{req.last} but current is #{current.join('.')}")
221 end
221 end
222 else
222 else
223 raise ArgumentError.new(":version option accepts a version string, an array or a range of versions")
223 raise ArgumentError.new(":version option accepts a version string, an array or a range of versions")
224 end
224 end
225 end
225 end
226 end
226 end
227 true
227 true
228 end
228 end
229
229
230 def compare_versions(requirement, current)
230 def compare_versions(requirement, current)
231 requirement = requirement.split('.').collect(&:to_i)
231 requirement = requirement.split('.').collect(&:to_i)
232 requirement <=> current.slice(0, requirement.size)
232 requirement <=> current.slice(0, requirement.size)
233 end
233 end
234 private :compare_versions
234 private :compare_versions
235
235
236 # Sets a requirement on a Redmine plugin version
236 # Sets a requirement on a Redmine plugin version
237 # Raises a PluginRequirementError exception if the requirement is not met
237 # Raises a PluginRequirementError exception if the requirement is not met
238 #
238 #
239 # Examples
239 # Examples
240 # # Requires a plugin named :foo version 0.7.3 or higher
240 # # Requires a plugin named :foo version 0.7.3 or higher
241 # requires_redmine_plugin :foo, :version_or_higher => '0.7.3'
241 # requires_redmine_plugin :foo, :version_or_higher => '0.7.3'
242 # requires_redmine_plugin :foo, '0.7.3'
242 # requires_redmine_plugin :foo, '0.7.3'
243 #
243 #
244 # # Requires a specific version of a Redmine plugin
244 # # Requires a specific version of a Redmine plugin
245 # requires_redmine_plugin :foo, :version => '0.7.3' # 0.7.3 only
245 # requires_redmine_plugin :foo, :version => '0.7.3' # 0.7.3 only
246 # requires_redmine_plugin :foo, :version => ['0.7.3', '0.8.0'] # 0.7.3 or 0.8.0
246 # requires_redmine_plugin :foo, :version => ['0.7.3', '0.8.0'] # 0.7.3 or 0.8.0
247 def requires_redmine_plugin(plugin_name, arg)
247 def requires_redmine_plugin(plugin_name, arg)
248 arg = { :version_or_higher => arg } unless arg.is_a?(Hash)
248 arg = { :version_or_higher => arg } unless arg.is_a?(Hash)
249 arg.assert_valid_keys(:version, :version_or_higher)
249 arg.assert_valid_keys(:version, :version_or_higher)
250
250
251 plugin = Plugin.find(plugin_name)
251 plugin = Plugin.find(plugin_name)
252 current = plugin.version.split('.').collect(&:to_i)
252 current = plugin.version.split('.').collect(&:to_i)
253
253
254 arg.each do |k, v|
254 arg.each do |k, v|
255 v = [] << v unless v.is_a?(Array)
255 v = [] << v unless v.is_a?(Array)
256 versions = v.collect {|s| s.split('.').collect(&:to_i)}
256 versions = v.collect {|s| s.split('.').collect(&:to_i)}
257 case k
257 case k
258 when :version_or_higher
258 when :version_or_higher
259 raise ArgumentError.new("wrong number of versions (#{versions.size} for 1)") unless versions.size == 1
259 raise ArgumentError.new("wrong number of versions (#{versions.size} for 1)") unless versions.size == 1
260 unless (current <=> versions.first) >= 0
260 unless (current <=> versions.first) >= 0
261 raise PluginRequirementError.new("#{id} plugin requires the #{plugin_name} plugin #{v} or higher but current is #{current.join('.')}")
261 raise PluginRequirementError.new("#{id} plugin requires the #{plugin_name} plugin #{v} or higher but current is #{current.join('.')}")
262 end
262 end
263 when :version
263 when :version
264 unless versions.include?(current.slice(0,3))
264 unless versions.include?(current.slice(0,3))
265 raise PluginRequirementError.new("#{id} plugin requires one the following versions of #{plugin_name}: #{v.join(', ')} but current is #{current.join('.')}")
265 raise PluginRequirementError.new("#{id} plugin requires one the following versions of #{plugin_name}: #{v.join(', ')} but current is #{current.join('.')}")
266 end
266 end
267 end
267 end
268 end
268 end
269 true
269 true
270 end
270 end
271
271
272 # Adds an item to the given +menu+.
272 # Adds an item to the given +menu+.
273 # The +id+ parameter (equals to the project id) is automatically added to the url.
273 # The +id+ parameter (equals to the project id) is automatically added to the url.
274 # menu :project_menu, :plugin_example, { :controller => 'example', :action => 'say_hello' }, :caption => 'Sample'
274 # menu :project_menu, :plugin_example, { :controller => 'example', :action => 'say_hello' }, :caption => 'Sample'
275 #
275 #
276 # +name+ parameter can be: :top_menu, :account_menu, :application_menu or :project_menu
276 # +name+ parameter can be: :top_menu, :account_menu, :application_menu or :project_menu
277 #
277 #
278 def menu(menu, item, url, options={})
278 def menu(menu, item, url, options={})
279 Redmine::MenuManager.map(menu).push(item, url, options)
279 Redmine::MenuManager.map(menu).push(item, url, options)
280 end
280 end
281 alias :add_menu_item :menu
281 alias :add_menu_item :menu
282
282
283 # Removes +item+ from the given +menu+.
283 # Removes +item+ from the given +menu+.
284 def delete_menu_item(menu, item)
284 def delete_menu_item(menu, item)
285 Redmine::MenuManager.map(menu).delete(item)
285 Redmine::MenuManager.map(menu).delete(item)
286 end
286 end
287
287
288 # Defines a permission called +name+ for the given +actions+.
288 # Defines a permission called +name+ for the given +actions+.
289 #
289 #
290 # The +actions+ argument is a hash with controllers as keys and actions as values (a single value or an array):
290 # The +actions+ argument is a hash with controllers as keys and actions as values (a single value or an array):
291 # permission :destroy_contacts, { :contacts => :destroy }
291 # permission :destroy_contacts, { :contacts => :destroy }
292 # permission :view_contacts, { :contacts => [:index, :show] }
292 # permission :view_contacts, { :contacts => [:index, :show] }
293 #
293 #
294 # The +options+ argument is a hash that accept the following keys:
294 # The +options+ argument is a hash that accept the following keys:
295 # * :public => the permission is public if set to true (implicitly given to any user)
295 # * :public => the permission is public if set to true (implicitly given to any user)
296 # * :require => can be set to one of the following values to restrict users the permission can be given to: :loggedin, :member
296 # * :require => can be set to one of the following values to restrict users the permission can be given to: :loggedin, :member
297 # * :read => set it to true so that the permission is still granted on closed projects
297 # * :read => set it to true so that the permission is still granted on closed projects
298 #
298 #
299 # Examples
299 # Examples
300 # # A permission that is implicitly given to any user
300 # # A permission that is implicitly given to any user
301 # # This permission won't appear on the Roles & Permissions setup screen
301 # # This permission won't appear on the Roles & Permissions setup screen
302 # permission :say_hello, { :example => :say_hello }, :public => true, :read => true
302 # permission :say_hello, { :example => :say_hello }, :public => true, :read => true
303 #
303 #
304 # # A permission that can be given to any user
304 # # A permission that can be given to any user
305 # permission :say_hello, { :example => :say_hello }
305 # permission :say_hello, { :example => :say_hello }
306 #
306 #
307 # # A permission that can be given to registered users only
307 # # A permission that can be given to registered users only
308 # permission :say_hello, { :example => :say_hello }, :require => :loggedin
308 # permission :say_hello, { :example => :say_hello }, :require => :loggedin
309 #
309 #
310 # # A permission that can be given to project members only
310 # # A permission that can be given to project members only
311 # permission :say_hello, { :example => :say_hello }, :require => :member
311 # permission :say_hello, { :example => :say_hello }, :require => :member
312 def permission(name, actions, options = {})
312 def permission(name, actions, options = {})
313 if @project_module
313 if @project_module
314 Redmine::AccessControl.map {|map| map.project_module(@project_module) {|map|map.permission(name, actions, options)}}
314 Redmine::AccessControl.map {|map| map.project_module(@project_module) {|map|map.permission(name, actions, options)}}
315 else
315 else
316 Redmine::AccessControl.map {|map| map.permission(name, actions, options)}
316 Redmine::AccessControl.map {|map| map.permission(name, actions, options)}
317 end
317 end
318 end
318 end
319
319
320 # Defines a project module, that can be enabled/disabled for each project.
320 # Defines a project module, that can be enabled/disabled for each project.
321 # Permissions defined inside +block+ will be bind to the module.
321 # Permissions defined inside +block+ will be bind to the module.
322 #
322 #
323 # project_module :things do
323 # project_module :things do
324 # permission :view_contacts, { :contacts => [:list, :show] }, :public => true
324 # permission :view_contacts, { :contacts => [:list, :show] }, :public => true
325 # permission :destroy_contacts, { :contacts => :destroy }
325 # permission :destroy_contacts, { :contacts => :destroy }
326 # end
326 # end
327 def project_module(name, &block)
327 def project_module(name, &block)
328 @project_module = name
328 @project_module = name
329 self.instance_eval(&block)
329 self.instance_eval(&block)
330 @project_module = nil
330 @project_module = nil
331 end
331 end
332
332
333 # Registers an activity provider.
333 # Registers an activity provider.
334 #
334 #
335 # Options:
335 # Options:
336 # * <tt>:class_name</tt> - one or more model(s) that provide these events (inferred from event_type by default)
336 # * <tt>:class_name</tt> - one or more model(s) that provide these events (inferred from event_type by default)
337 # * <tt>:default</tt> - setting this option to false will make the events not displayed by default
337 # * <tt>:default</tt> - setting this option to false will make the events not displayed by default
338 #
338 #
339 # A model can provide several activity event types.
339 # A model can provide several activity event types.
340 #
340 #
341 # Examples:
341 # Examples:
342 # register :news
342 # register :news
343 # register :scrums, :class_name => 'Meeting'
343 # register :scrums, :class_name => 'Meeting'
344 # register :issues, :class_name => ['Issue', 'Journal']
344 # register :issues, :class_name => ['Issue', 'Journal']
345 #
345 #
346 # Retrieving events:
346 # Retrieving events:
347 # Associated model(s) must implement the find_events class method.
347 # Associated model(s) must implement the find_events class method.
348 # ActiveRecord models can use acts_as_activity_provider as a way to implement this class method.
348 # ActiveRecord models can use acts_as_activity_provider as a way to implement this class method.
349 #
349 #
350 # The following call should return all the scrum events visible by current user that occurred in the 5 last days:
350 # The following call should return all the scrum events visible by current user that occurred in the 5 last days:
351 # Meeting.find_events('scrums', User.current, 5.days.ago, Date.today)
351 # Meeting.find_events('scrums', User.current, 5.days.ago, Date.today)
352 # Meeting.find_events('scrums', User.current, 5.days.ago, Date.today, :project => foo) # events for project foo only
352 # Meeting.find_events('scrums', User.current, 5.days.ago, Date.today, :project => foo) # events for project foo only
353 #
353 #
354 # Note that :view_scrums permission is required to view these events in the activity view.
354 # Note that :view_scrums permission is required to view these events in the activity view.
355 def activity_provider(*args)
355 def activity_provider(*args)
356 Redmine::Activity.register(*args)
356 Redmine::Activity.register(*args)
357 end
357 end
358
358
359 # Registers a wiki formatter.
359 # Registers a wiki formatter.
360 #
360 #
361 # Parameters:
361 # Parameters:
362 # * +name+ - human-readable name
362 # * +name+ - formatter name
363 # * +formatter+ - formatter class, which should have an instance method +to_html+
363 # * +formatter+ - formatter class, which should have an instance method +to_html+
364 # * +helper+ - helper module, which will be included by wiki pages
364 # * +helper+ - helper module, which will be included by wiki pages (optional)
365 def wiki_format_provider(name, formatter, helper)
365 # * +html_parser+ class reponsible for converting HTML to wiki text (optional)
366 Redmine::WikiFormatting.register(name, formatter, helper)
366 # * +options+ - a Hash of options (optional)
367 # * :label - label for the formatter displayed in application settings
368 #
369 # Examples:
370 # wiki_format_provider(:custom_formatter, CustomFormatter, :label => "My custom formatter")
371 #
372 def wiki_format_provider(name, *args)
373 Redmine::WikiFormatting.register(name, *args)
367 end
374 end
368
375
369 # Returns +true+ if the plugin can be configured.
376 # Returns +true+ if the plugin can be configured.
370 def configurable?
377 def configurable?
371 settings && settings.is_a?(Hash) && !settings[:partial].blank?
378 settings && settings.is_a?(Hash) && !settings[:partial].blank?
372 end
379 end
373
380
374 def mirror_assets
381 def mirror_assets
375 source = assets_directory
382 source = assets_directory
376 destination = public_directory
383 destination = public_directory
377 return unless File.directory?(source)
384 return unless File.directory?(source)
378
385
379 source_files = Dir[source + "/**/*"]
386 source_files = Dir[source + "/**/*"]
380 source_dirs = source_files.select { |d| File.directory?(d) }
387 source_dirs = source_files.select { |d| File.directory?(d) }
381 source_files -= source_dirs
388 source_files -= source_dirs
382
389
383 unless source_files.empty?
390 unless source_files.empty?
384 base_target_dir = File.join(destination, File.dirname(source_files.first).gsub(source, ''))
391 base_target_dir = File.join(destination, File.dirname(source_files.first).gsub(source, ''))
385 begin
392 begin
386 FileUtils.mkdir_p(base_target_dir)
393 FileUtils.mkdir_p(base_target_dir)
387 rescue Exception => e
394 rescue Exception => e
388 raise "Could not create directory #{base_target_dir}: " + e.message
395 raise "Could not create directory #{base_target_dir}: " + e.message
389 end
396 end
390 end
397 end
391
398
392 source_dirs.each do |dir|
399 source_dirs.each do |dir|
393 # strip down these paths so we have simple, relative paths we can
400 # strip down these paths so we have simple, relative paths we can
394 # add to the destination
401 # add to the destination
395 target_dir = File.join(destination, dir.gsub(source, ''))
402 target_dir = File.join(destination, dir.gsub(source, ''))
396 begin
403 begin
397 FileUtils.mkdir_p(target_dir)
404 FileUtils.mkdir_p(target_dir)
398 rescue Exception => e
405 rescue Exception => e
399 raise "Could not create directory #{target_dir}: " + e.message
406 raise "Could not create directory #{target_dir}: " + e.message
400 end
407 end
401 end
408 end
402
409
403 source_files.each do |file|
410 source_files.each do |file|
404 begin
411 begin
405 target = File.join(destination, file.gsub(source, ''))
412 target = File.join(destination, file.gsub(source, ''))
406 unless File.exist?(target) && FileUtils.identical?(file, target)
413 unless File.exist?(target) && FileUtils.identical?(file, target)
407 FileUtils.cp(file, target)
414 FileUtils.cp(file, target)
408 end
415 end
409 rescue Exception => e
416 rescue Exception => e
410 raise "Could not copy #{file} to #{target}: " + e.message
417 raise "Could not copy #{file} to #{target}: " + e.message
411 end
418 end
412 end
419 end
413 end
420 end
414
421
415 # Mirrors assets from one or all plugins to public/plugin_assets
422 # Mirrors assets from one or all plugins to public/plugin_assets
416 def self.mirror_assets(name=nil)
423 def self.mirror_assets(name=nil)
417 if name.present?
424 if name.present?
418 find(name).mirror_assets
425 find(name).mirror_assets
419 else
426 else
420 all.each do |plugin|
427 all.each do |plugin|
421 plugin.mirror_assets
428 plugin.mirror_assets
422 end
429 end
423 end
430 end
424 end
431 end
425
432
426 # The directory containing this plugin's migrations (<tt>plugin/db/migrate</tt>)
433 # The directory containing this plugin's migrations (<tt>plugin/db/migrate</tt>)
427 def migration_directory
434 def migration_directory
428 File.join(Rails.root, 'plugins', id.to_s, 'db', 'migrate')
435 File.join(Rails.root, 'plugins', id.to_s, 'db', 'migrate')
429 end
436 end
430
437
431 # Returns the version number of the latest migration for this plugin. Returns
438 # Returns the version number of the latest migration for this plugin. Returns
432 # nil if this plugin has no migrations.
439 # nil if this plugin has no migrations.
433 def latest_migration
440 def latest_migration
434 migrations.last
441 migrations.last
435 end
442 end
436
443
437 # Returns the version numbers of all migrations for this plugin.
444 # Returns the version numbers of all migrations for this plugin.
438 def migrations
445 def migrations
439 migrations = Dir[migration_directory+"/*.rb"]
446 migrations = Dir[migration_directory+"/*.rb"]
440 migrations.map { |p| File.basename(p).match(/0*(\d+)\_/)[1].to_i }.sort
447 migrations.map { |p| File.basename(p).match(/0*(\d+)\_/)[1].to_i }.sort
441 end
448 end
442
449
443 # Migrate this plugin to the given version
450 # Migrate this plugin to the given version
444 def migrate(version = nil)
451 def migrate(version = nil)
445 puts "Migrating #{id} (#{name})..."
452 puts "Migrating #{id} (#{name})..."
446 Redmine::Plugin::Migrator.migrate_plugin(self, version)
453 Redmine::Plugin::Migrator.migrate_plugin(self, version)
447 end
454 end
448
455
449 # Migrates all plugins or a single plugin to a given version
456 # Migrates all plugins or a single plugin to a given version
450 # Exemples:
457 # Exemples:
451 # Plugin.migrate
458 # Plugin.migrate
452 # Plugin.migrate('sample_plugin')
459 # Plugin.migrate('sample_plugin')
453 # Plugin.migrate('sample_plugin', 1)
460 # Plugin.migrate('sample_plugin', 1)
454 #
461 #
455 def self.migrate(name=nil, version=nil)
462 def self.migrate(name=nil, version=nil)
456 if name.present?
463 if name.present?
457 find(name).migrate(version)
464 find(name).migrate(version)
458 else
465 else
459 all.each do |plugin|
466 all.each do |plugin|
460 plugin.migrate
467 plugin.migrate
461 end
468 end
462 end
469 end
463 end
470 end
464
471
465 class Migrator < ActiveRecord::Migrator
472 class Migrator < ActiveRecord::Migrator
466 # We need to be able to set the 'current' plugin being migrated.
473 # We need to be able to set the 'current' plugin being migrated.
467 cattr_accessor :current_plugin
474 cattr_accessor :current_plugin
468
475
469 class << self
476 class << self
470 # Runs the migrations from a plugin, up (or down) to the version given
477 # Runs the migrations from a plugin, up (or down) to the version given
471 def migrate_plugin(plugin, version)
478 def migrate_plugin(plugin, version)
472 self.current_plugin = plugin
479 self.current_plugin = plugin
473 return if current_version(plugin) == version
480 return if current_version(plugin) == version
474 migrate(plugin.migration_directory, version)
481 migrate(plugin.migration_directory, version)
475 end
482 end
476
483
477 def current_version(plugin=current_plugin)
484 def current_version(plugin=current_plugin)
478 # Delete migrations that don't match .. to_i will work because the number comes first
485 # Delete migrations that don't match .. to_i will work because the number comes first
479 ::ActiveRecord::Base.connection.select_values(
486 ::ActiveRecord::Base.connection.select_values(
480 "SELECT version FROM #{schema_migrations_table_name}"
487 "SELECT version FROM #{schema_migrations_table_name}"
481 ).delete_if{ |v| v.match(/-#{plugin.id}$/) == nil }.map(&:to_i).max || 0
488 ).delete_if{ |v| v.match(/-#{plugin.id}$/) == nil }.map(&:to_i).max || 0
482 end
489 end
483 end
490 end
484
491
485 def migrated
492 def migrated
486 sm_table = self.class.schema_migrations_table_name
493 sm_table = self.class.schema_migrations_table_name
487 ::ActiveRecord::Base.connection.select_values(
494 ::ActiveRecord::Base.connection.select_values(
488 "SELECT version FROM #{sm_table}"
495 "SELECT version FROM #{sm_table}"
489 ).delete_if{ |v| v.match(/-#{current_plugin.id}$/) == nil }.map(&:to_i).sort
496 ).delete_if{ |v| v.match(/-#{current_plugin.id}$/) == nil }.map(&:to_i).sort
490 end
497 end
491
498
492 def record_version_state_after_migrating(version)
499 def record_version_state_after_migrating(version)
493 super(version.to_s + "-" + current_plugin.id.to_s)
500 super(version.to_s + "-" + current_plugin.id.to_s)
494 end
501 end
495 end
502 end
496 end
503 end
497 end
504 end
@@ -1,198 +1,200
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require 'digest/md5'
18 require 'digest/md5'
19
19
20 module Redmine
20 module Redmine
21 module WikiFormatting
21 module WikiFormatting
22 class StaleSectionError < Exception; end
22 class StaleSectionError < Exception; end
23
23
24 @@formatters = {}
24 @@formatters = {}
25
25
26 class << self
26 class << self
27 def map
27 def map
28 yield self
28 yield self
29 end
29 end
30
30
31 def register(name, *args)
31 def register(name, *args)
32 options = args.last.is_a?(Hash) ? args.pop : {}
32 options = args.last.is_a?(Hash) ? args.pop : {}
33 name = name.to_s
33 name = name.to_s
34 raise ArgumentError, "format name '#{name}' is already taken" if @@formatters[name]
34 raise ArgumentError, "format name '#{name}' is already taken" if @@formatters[name]
35
35
36 formatter, helper, parser = args.any? ?
36 formatter, helper, parser = args.any? ?
37 args :
37 args :
38 %w(Formatter Helper HtmlParser).map {|m| "Redmine::WikiFormatting::#{name.classify}::#{m}".constantize}
38 %w(Formatter Helper HtmlParser).map {|m| "Redmine::WikiFormatting::#{name.classify}::#{m}".constantize rescue nil}
39
40 raise "A formatter class is required" if formatter.nil?
39
41
40 @@formatters[name] = {
42 @@formatters[name] = {
41 :formatter => formatter,
43 :formatter => formatter,
42 :helper => helper,
44 :helper => helper,
43 :html_parser => parser,
45 :html_parser => parser,
44 :label => options[:label] || name.humanize
46 :label => options[:label] || name.humanize
45 }
47 }
46 end
48 end
47
49
48 def formatter
50 def formatter
49 formatter_for(Setting.text_formatting)
51 formatter_for(Setting.text_formatting)
50 end
52 end
51
53
52 def html_parser
54 def html_parser
53 html_parser_for(Setting.text_formatting)
55 html_parser_for(Setting.text_formatting)
54 end
56 end
55
57
56 def formatter_for(name)
58 def formatter_for(name)
57 entry = @@formatters[name.to_s]
59 entry = @@formatters[name.to_s]
58 (entry && entry[:formatter]) || Redmine::WikiFormatting::NullFormatter::Formatter
60 (entry && entry[:formatter]) || Redmine::WikiFormatting::NullFormatter::Formatter
59 end
61 end
60
62
61 def helper_for(name)
63 def helper_for(name)
62 entry = @@formatters[name.to_s]
64 entry = @@formatters[name.to_s]
63 (entry && entry[:helper]) || Redmine::WikiFormatting::NullFormatter::Helper
65 (entry && entry[:helper]) || Redmine::WikiFormatting::NullFormatter::Helper
64 end
66 end
65
67
66 def html_parser_for(name)
68 def html_parser_for(name)
67 entry = @@formatters[name.to_s]
69 entry = @@formatters[name.to_s]
68 (entry && entry[:html_parser]) || Redmine::WikiFormatting::HtmlParser
70 (entry && entry[:html_parser]) || Redmine::WikiFormatting::HtmlParser
69 end
71 end
70
72
71 def format_names
73 def format_names
72 @@formatters.keys.map
74 @@formatters.keys.map
73 end
75 end
74
76
75 def formats_for_select
77 def formats_for_select
76 @@formatters.map {|name, options| [options[:label], name]}
78 @@formatters.map {|name, options| [options[:label], name]}
77 end
79 end
78
80
79 def to_html(format, text, options = {})
81 def to_html(format, text, options = {})
80 text = if Setting.cache_formatted_text? && text.size > 2.kilobyte && cache_store && cache_key = cache_key_for(format, text, options[:object], options[:attribute])
82 text = if Setting.cache_formatted_text? && text.size > 2.kilobyte && cache_store && cache_key = cache_key_for(format, text, options[:object], options[:attribute])
81 # Text retrieved from the cache store may be frozen
83 # Text retrieved from the cache store may be frozen
82 # We need to dup it so we can do in-place substitutions with gsub!
84 # We need to dup it so we can do in-place substitutions with gsub!
83 cache_store.fetch cache_key do
85 cache_store.fetch cache_key do
84 formatter_for(format).new(text).to_html
86 formatter_for(format).new(text).to_html
85 end.dup
87 end.dup
86 else
88 else
87 formatter_for(format).new(text).to_html
89 formatter_for(format).new(text).to_html
88 end
90 end
89 text
91 text
90 end
92 end
91
93
92 # Returns true if the text formatter supports single section edit
94 # Returns true if the text formatter supports single section edit
93 def supports_section_edit?
95 def supports_section_edit?
94 (formatter.instance_methods & ['update_section', :update_section]).any?
96 (formatter.instance_methods & ['update_section', :update_section]).any?
95 end
97 end
96
98
97 # Returns a cache key for the given text +format+, +text+, +object+ and +attribute+ or nil if no caching should be done
99 # Returns a cache key for the given text +format+, +text+, +object+ and +attribute+ or nil if no caching should be done
98 def cache_key_for(format, text, object, attribute)
100 def cache_key_for(format, text, object, attribute)
99 if object && attribute && !object.new_record? && format.present?
101 if object && attribute && !object.new_record? && format.present?
100 "formatted_text/#{format}/#{object.class.model_name.cache_key}/#{object.id}-#{attribute}-#{Digest::MD5.hexdigest text}"
102 "formatted_text/#{format}/#{object.class.model_name.cache_key}/#{object.id}-#{attribute}-#{Digest::MD5.hexdigest text}"
101 end
103 end
102 end
104 end
103
105
104 # Returns the cache store used to cache HTML output
106 # Returns the cache store used to cache HTML output
105 def cache_store
107 def cache_store
106 ActionController::Base.cache_store
108 ActionController::Base.cache_store
107 end
109 end
108 end
110 end
109
111
110 module LinksHelper
112 module LinksHelper
111 AUTO_LINK_RE = %r{
113 AUTO_LINK_RE = %r{
112 ( # leading text
114 ( # leading text
113 <\w+[^>]*?>| # leading HTML tag, or
115 <\w+[^>]*?>| # leading HTML tag, or
114 [\s\(\[,;]| # leading punctuation, or
116 [\s\(\[,;]| # leading punctuation, or
115 ^ # beginning of line
117 ^ # beginning of line
116 )
118 )
117 (
119 (
118 (?:https?://)| # protocol spec, or
120 (?:https?://)| # protocol spec, or
119 (?:s?ftps?://)|
121 (?:s?ftps?://)|
120 (?:www\.) # www.*
122 (?:www\.) # www.*
121 )
123 )
122 (
124 (
123 ([^<]\S*?) # url
125 ([^<]\S*?) # url
124 (\/)? # slash
126 (\/)? # slash
125 )
127 )
126 ((?:&gt;)?|[^[:alnum:]_\=\/;\(\)]*?) # post
128 ((?:&gt;)?|[^[:alnum:]_\=\/;\(\)]*?) # post
127 (?=<|\s|$)
129 (?=<|\s|$)
128 }x unless const_defined?(:AUTO_LINK_RE)
130 }x unless const_defined?(:AUTO_LINK_RE)
129
131
130 # Destructively replaces urls into clickable links
132 # Destructively replaces urls into clickable links
131 def auto_link!(text)
133 def auto_link!(text)
132 text.gsub!(AUTO_LINK_RE) do
134 text.gsub!(AUTO_LINK_RE) do
133 all, leading, proto, url, post = $&, $1, $2, $3, $6
135 all, leading, proto, url, post = $&, $1, $2, $3, $6
134 if leading =~ /<a\s/i || leading =~ /![<>=]?/
136 if leading =~ /<a\s/i || leading =~ /![<>=]?/
135 # don't replace URLs that are already linked
137 # don't replace URLs that are already linked
136 # and URLs prefixed with ! !> !< != (textile images)
138 # and URLs prefixed with ! !> !< != (textile images)
137 all
139 all
138 else
140 else
139 # Idea below : an URL with unbalanced parenthesis and
141 # Idea below : an URL with unbalanced parenthesis and
140 # ending by ')' is put into external parenthesis
142 # ending by ')' is put into external parenthesis
141 if ( url[-1]==?) and ((url.count("(") - url.count(")")) < 0 ) )
143 if ( url[-1]==?) and ((url.count("(") - url.count(")")) < 0 ) )
142 url=url[0..-2] # discard closing parenthesis from url
144 url=url[0..-2] # discard closing parenthesis from url
143 post = ")"+post # add closing parenthesis to post
145 post = ")"+post # add closing parenthesis to post
144 end
146 end
145 content = proto + url
147 content = proto + url
146 href = "#{proto=="www."?"http://www.":proto}#{url}"
148 href = "#{proto=="www."?"http://www.":proto}#{url}"
147 %(#{leading}<a class="external" href="#{ERB::Util.html_escape href}">#{ERB::Util.html_escape content}</a>#{post}).html_safe
149 %(#{leading}<a class="external" href="#{ERB::Util.html_escape href}">#{ERB::Util.html_escape content}</a>#{post}).html_safe
148 end
150 end
149 end
151 end
150 end
152 end
151
153
152 # Destructively replaces email addresses into clickable links
154 # Destructively replaces email addresses into clickable links
153 def auto_mailto!(text)
155 def auto_mailto!(text)
154 text.gsub!(/([\w\.!#\$%\-+.\/]+@[A-Za-z0-9\-]+(\.[A-Za-z0-9\-]+)+)/) do
156 text.gsub!(/([\w\.!#\$%\-+.\/]+@[A-Za-z0-9\-]+(\.[A-Za-z0-9\-]+)+)/) do
155 mail = $1
157 mail = $1
156 if text.match(/<a\b[^>]*>(.*)(#{Regexp.escape(mail)})(.*)<\/a>/)
158 if text.match(/<a\b[^>]*>(.*)(#{Regexp.escape(mail)})(.*)<\/a>/)
157 mail
159 mail
158 else
160 else
159 %(<a class="email" href="mailto:#{ERB::Util.html_escape mail}">#{ERB::Util.html_escape mail}</a>).html_safe
161 %(<a class="email" href="mailto:#{ERB::Util.html_escape mail}">#{ERB::Util.html_escape mail}</a>).html_safe
160 end
162 end
161 end
163 end
162 end
164 end
163 end
165 end
164
166
165 # Default formatter module
167 # Default formatter module
166 module NullFormatter
168 module NullFormatter
167 class Formatter
169 class Formatter
168 include ActionView::Helpers::TagHelper
170 include ActionView::Helpers::TagHelper
169 include ActionView::Helpers::TextHelper
171 include ActionView::Helpers::TextHelper
170 include ActionView::Helpers::UrlHelper
172 include ActionView::Helpers::UrlHelper
171 include Redmine::WikiFormatting::LinksHelper
173 include Redmine::WikiFormatting::LinksHelper
172
174
173 def initialize(text)
175 def initialize(text)
174 @text = text
176 @text = text
175 end
177 end
176
178
177 def to_html(*args)
179 def to_html(*args)
178 t = CGI::escapeHTML(@text)
180 t = CGI::escapeHTML(@text)
179 auto_link!(t)
181 auto_link!(t)
180 auto_mailto!(t)
182 auto_mailto!(t)
181 simple_format(t, {}, :sanitize => false)
183 simple_format(t, {}, :sanitize => false)
182 end
184 end
183 end
185 end
184
186
185 module Helper
187 module Helper
186 def wikitoolbar_for(field_id)
188 def wikitoolbar_for(field_id)
187 end
189 end
188
190
189 def heads_for_wiki_formatter
191 def heads_for_wiki_formatter
190 end
192 end
191
193
192 def initial_page_content(page)
194 def initial_page_content(page)
193 page.pretty_title.to_s
195 page.pretty_title.to_s
194 end
196 end
195 end
197 end
196 end
198 end
197 end
199 end
198 end
200 end
General Comments 0
You need to be logged in to leave comments. Login now