2013-08-23 02:21:52 -04:00
|
|
|
require 'digest/sha1'
|
|
|
|
require 'fileutils'
|
|
|
|
require_dependency 'plugin/metadata'
|
|
|
|
require_dependency 'plugin/auth_provider'
|
|
|
|
|
|
|
|
class Plugin::Instance
|
|
|
|
|
2013-08-25 21:04:16 -04:00
|
|
|
attr_accessor :path, :metadata
|
2015-02-06 17:32:59 -05:00
|
|
|
attr_reader :admin_route
|
2013-08-23 02:21:52 -04:00
|
|
|
|
2015-02-04 12:59:18 -05:00
|
|
|
# Memoized array readers
|
|
|
|
[:assets, :auth_providers, :color_schemes, :initializers, :javascripts, :styles].each do |att|
|
|
|
|
class_eval %Q{
|
|
|
|
def #{att}
|
|
|
|
@#{att} ||= []
|
|
|
|
end
|
|
|
|
}
|
|
|
|
end
|
|
|
|
|
2015-11-05 11:25:26 -05:00
|
|
|
# Memoized hash readers
|
|
|
|
[:seed_data, :emojis].each do |att|
|
|
|
|
class_eval %Q{
|
|
|
|
def #{att}
|
|
|
|
@#{att} ||= HashWithIndifferentAccess.new({})
|
|
|
|
end
|
|
|
|
}
|
2015-06-04 15:56:17 -04:00
|
|
|
end
|
|
|
|
|
2013-08-23 02:21:52 -04:00
|
|
|
def self.find_all(parent_path)
|
|
|
|
[].tap { |plugins|
|
2013-10-21 05:18:24 -04:00
|
|
|
# also follows symlinks - http://stackoverflow.com/q/357754
|
2014-11-03 15:26:06 -05:00
|
|
|
Dir["#{parent_path}/**/*/**/plugin.rb"].sort.each do |path|
|
2013-08-23 02:21:52 -04:00
|
|
|
source = File.read(path)
|
|
|
|
metadata = Plugin::Metadata.parse(source)
|
|
|
|
plugins << self.new(metadata, path)
|
|
|
|
end
|
|
|
|
}
|
|
|
|
end
|
|
|
|
|
2013-08-25 21:04:16 -04:00
|
|
|
def initialize(metadata=nil, path=nil)
|
2013-08-23 02:21:52 -04:00
|
|
|
@metadata = metadata
|
|
|
|
@path = path
|
2015-08-21 11:28:17 -04:00
|
|
|
@idx = 0
|
2013-08-23 02:21:52 -04:00
|
|
|
end
|
|
|
|
|
2015-02-06 17:32:59 -05:00
|
|
|
def add_admin_route(label, location)
|
|
|
|
@admin_route = {label: label, location: location}
|
|
|
|
end
|
|
|
|
|
2015-02-04 16:23:39 -05:00
|
|
|
def enabled?
|
2015-04-23 13:33:29 -04:00
|
|
|
@enabled_site_setting ? SiteSetting.send(@enabled_site_setting) : true
|
2013-08-25 21:04:16 -04:00
|
|
|
end
|
|
|
|
|
2015-02-04 16:23:39 -05:00
|
|
|
delegate :name, to: :metadata
|
|
|
|
|
2015-04-23 13:33:29 -04:00
|
|
|
def add_to_serializer(serializer, attr, define_include_method=true, &block)
|
2015-11-04 14:53:45 -05:00
|
|
|
klass = "#{serializer.to_s.classify}Serializer".constantize rescue "#{serializer.to_s}Serializer".constantize
|
2015-04-23 13:33:29 -04:00
|
|
|
|
|
|
|
klass.attributes(attr) unless attr.to_s.start_with?("include_")
|
|
|
|
|
2015-01-12 10:52:55 -05:00
|
|
|
klass.send(:define_method, attr, &block)
|
2015-02-04 16:23:39 -05:00
|
|
|
|
2015-04-23 13:33:29 -04:00
|
|
|
return unless define_include_method
|
|
|
|
|
2015-02-04 16:23:39 -05:00
|
|
|
# Don't include serialized methods if the plugin is disabled
|
|
|
|
plugin = self
|
2015-04-23 13:33:29 -04:00
|
|
|
klass.send(:define_method, "include_#{attr}?") { plugin.enabled? }
|
2015-02-04 16:23:39 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
# Extend a class but check that the plugin is enabled
|
2015-08-21 11:28:17 -04:00
|
|
|
# for class methods use `add_class_method`
|
2015-02-04 16:23:39 -05:00
|
|
|
def add_to_class(klass, attr, &block)
|
2015-11-04 14:53:45 -05:00
|
|
|
klass = klass.to_s.classify.constantize rescue klass.to_s.constantize
|
2015-02-04 16:23:39 -05:00
|
|
|
|
2015-04-23 13:33:29 -04:00
|
|
|
hidden_method_name = :"#{attr}_without_enable_check"
|
2015-04-23 14:02:16 -04:00
|
|
|
klass.send(:define_method, hidden_method_name, &block)
|
2015-02-04 16:23:39 -05:00
|
|
|
|
|
|
|
plugin = self
|
|
|
|
klass.send(:define_method, attr) do |*args|
|
|
|
|
send(hidden_method_name, *args) if plugin.enabled?
|
|
|
|
end
|
2015-01-12 10:52:55 -05:00
|
|
|
end
|
|
|
|
|
2015-08-21 11:28:17 -04:00
|
|
|
# Adds a class method to a class, respecting if plugin is enabled
|
|
|
|
def add_class_method(klass, attr, &block)
|
2015-09-14 15:34:28 -04:00
|
|
|
klass = klass.to_s.classify.constantize rescue klass.to_s.constantize
|
2015-08-21 11:28:17 -04:00
|
|
|
|
|
|
|
hidden_method_name = :"#{attr}_without_enable_check"
|
|
|
|
klass.send(:define_singleton_method, hidden_method_name, &block)
|
|
|
|
|
|
|
|
plugin = self
|
|
|
|
klass.send(:define_singleton_method, attr) do |*args|
|
|
|
|
send(hidden_method_name, *args) if plugin.enabled?
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def add_model_callback(klass, callback, &block)
|
2015-09-14 15:34:28 -04:00
|
|
|
klass = klass.to_s.classify.constantize rescue klass.to_s.constantize
|
2015-08-21 11:28:17 -04:00
|
|
|
plugin = self
|
|
|
|
|
|
|
|
# generate a unique method name
|
|
|
|
method_name = "#{plugin.name}_#{klass.name}_#{callback}#{@idx}".underscore
|
2015-08-21 11:39:40 -04:00
|
|
|
@idx += 1
|
2015-08-21 11:28:17 -04:00
|
|
|
hidden_method_name = :"#{method_name}_without_enable_check"
|
|
|
|
klass.send(:define_method, hidden_method_name, &block)
|
|
|
|
|
|
|
|
klass.send(callback) do |*args|
|
|
|
|
send(hidden_method_name, *args) if plugin.enabled?
|
|
|
|
end
|
|
|
|
|
|
|
|
end
|
|
|
|
|
2015-04-23 13:33:29 -04:00
|
|
|
# Add validation method but check that the plugin is enabled
|
2015-04-25 18:12:19 -04:00
|
|
|
def validate(klass, name, &block)
|
2015-04-23 13:33:29 -04:00
|
|
|
klass = klass.to_s.classify.constantize
|
2015-04-25 18:12:19 -04:00
|
|
|
klass.send(:define_method, name, &block)
|
2015-04-23 13:33:29 -04:00
|
|
|
|
|
|
|
plugin = self
|
2015-04-25 18:12:19 -04:00
|
|
|
klass.validate(name, if: -> { plugin.enabled? })
|
2015-04-23 13:33:29 -04:00
|
|
|
end
|
|
|
|
|
2013-08-23 02:21:52 -04:00
|
|
|
# will make sure all the assets this plugin needs are registered
|
|
|
|
def generate_automatic_assets!
|
|
|
|
paths = []
|
2015-11-06 09:02:40 -05:00
|
|
|
assets = []
|
|
|
|
|
2013-08-23 02:21:52 -04:00
|
|
|
automatic_assets.each do |path, contents|
|
2015-11-06 09:02:40 -05:00
|
|
|
write_asset(path, contents)
|
|
|
|
paths << path
|
|
|
|
assets << [path]
|
|
|
|
end
|
|
|
|
|
|
|
|
automatic_server_assets.each do |path, contents|
|
|
|
|
write_asset(path, contents)
|
2013-08-23 02:21:52 -04:00
|
|
|
paths << path
|
2015-11-06 09:02:40 -05:00
|
|
|
assets << [path, :server_side]
|
2013-08-23 02:21:52 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
delete_extra_automatic_assets(paths)
|
|
|
|
|
2015-11-06 09:02:40 -05:00
|
|
|
assets
|
2013-08-23 02:21:52 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def delete_extra_automatic_assets(good_paths)
|
2013-09-20 17:39:14 -04:00
|
|
|
return unless Dir.exists? auto_generated_path
|
|
|
|
|
2013-08-23 02:21:52 -04:00
|
|
|
filenames = good_paths.map{|f| File.basename(f)}
|
|
|
|
# nuke old files
|
|
|
|
Dir.foreach(auto_generated_path) do |p|
|
|
|
|
next if [".", ".."].include?(p)
|
|
|
|
next if filenames.include?(p)
|
|
|
|
File.delete(auto_generated_path + "/#{p}")
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def ensure_directory(path)
|
|
|
|
dirname = File.dirname(path)
|
|
|
|
unless File.directory?(dirname)
|
|
|
|
FileUtils.mkdir_p(dirname)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def auto_generated_path
|
|
|
|
File.dirname(path) << "/auto_generated"
|
|
|
|
end
|
|
|
|
|
2013-09-16 20:23:21 -04:00
|
|
|
def after_initialize(&block)
|
2015-02-04 12:59:18 -05:00
|
|
|
initializers << block
|
2013-09-16 20:23:21 -04:00
|
|
|
end
|
|
|
|
|
2015-02-04 16:23:39 -05:00
|
|
|
# A proxy to `DiscourseEvent.on` which does nothing if the plugin is disabled
|
|
|
|
def on(event_name, &block)
|
|
|
|
DiscourseEvent.on(event_name) do |*args|
|
|
|
|
block.call(*args) if enabled?
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2013-09-16 20:23:21 -04:00
|
|
|
def notify_after_initialize
|
2014-06-03 12:36:34 -04:00
|
|
|
color_schemes.each do |c|
|
|
|
|
ColorScheme.create_from_base(name: c[:name], colors: c[:colors]) unless ColorScheme.where(name: c[:name]).exists?
|
|
|
|
end
|
|
|
|
|
2015-02-04 12:59:18 -05:00
|
|
|
initializers.each do |callback|
|
2015-08-25 16:38:25 -04:00
|
|
|
begin
|
|
|
|
callback.call(self)
|
|
|
|
rescue ActiveRecord::StatementInvalid => e
|
|
|
|
# When running db:migrate for the first time on a new database, plugin initializers might
|
|
|
|
# try to use models. Tolerate it.
|
|
|
|
raise e unless e.message.try(:include?, "PG::UndefinedTable")
|
|
|
|
end
|
2013-09-16 20:23:21 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2014-12-11 11:08:47 -05:00
|
|
|
def listen_for(event_name)
|
|
|
|
return unless self.respond_to?(event_name)
|
|
|
|
DiscourseEvent.on(event_name, &self.method(event_name))
|
|
|
|
end
|
|
|
|
|
2013-08-23 02:21:52 -04:00
|
|
|
def register_css(style)
|
2015-02-04 12:59:18 -05:00
|
|
|
styles << style
|
2013-08-23 02:21:52 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def register_javascript(js)
|
2015-02-04 12:59:18 -05:00
|
|
|
javascripts << js
|
2013-08-23 02:21:52 -04:00
|
|
|
end
|
|
|
|
|
2014-06-04 21:39:33 -04:00
|
|
|
def register_custom_html(hash)
|
|
|
|
DiscoursePluginRegistry.custom_html ||= {}
|
|
|
|
DiscoursePluginRegistry.custom_html.merge!(hash)
|
|
|
|
end
|
|
|
|
|
2014-04-07 10:33:35 -04:00
|
|
|
def register_asset(file, opts=nil)
|
2013-08-23 02:21:52 -04:00
|
|
|
full_path = File.dirname(path) << "/assets/" << file
|
2014-04-10 02:30:22 -04:00
|
|
|
assets << [full_path, opts]
|
2013-08-23 02:21:52 -04:00
|
|
|
end
|
|
|
|
|
2014-06-03 12:36:34 -04:00
|
|
|
def register_color_scheme(name, colors)
|
|
|
|
color_schemes << {name: name, colors: colors}
|
2015-06-04 15:56:17 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def register_seed_data(key, value)
|
|
|
|
seed_data[key] = value
|
|
|
|
end
|
2014-06-03 12:36:34 -04:00
|
|
|
|
2015-11-05 11:25:26 -05:00
|
|
|
def register_emoji(name, url)
|
|
|
|
emojis[name] = url
|
|
|
|
end
|
|
|
|
|
2013-08-23 02:21:52 -04:00
|
|
|
def automatic_assets
|
2015-02-04 12:59:18 -05:00
|
|
|
css = styles.join("\n")
|
|
|
|
js = javascripts.join("\n")
|
2013-08-23 02:21:52 -04:00
|
|
|
|
2015-02-04 12:59:18 -05:00
|
|
|
auth_providers.each do |auth|
|
2013-08-23 02:21:52 -04:00
|
|
|
|
2015-09-25 11:29:05 -04:00
|
|
|
js << "Discourse.LoginMethod.register(Discourse.LoginMethod.create(#{auth.to_json}));\n"
|
2013-08-23 02:21:52 -04:00
|
|
|
|
2015-02-04 12:59:18 -05:00
|
|
|
if auth.glyph
|
|
|
|
css << ".btn-social.#{auth.name}:before{ content: '#{auth.glyph}'; }\n"
|
|
|
|
end
|
2013-08-23 02:21:52 -04:00
|
|
|
|
2015-02-04 12:59:18 -05:00
|
|
|
if auth.background_color
|
|
|
|
css << ".btn-social.#{auth.name}{ background: #{auth.background_color}; }\n"
|
2013-08-23 02:21:52 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-11-06 09:02:40 -05:00
|
|
|
# Generate an IIFE for the JS
|
|
|
|
js = "(function(){#{js}})();" if js.present?
|
|
|
|
|
|
|
|
result = []
|
|
|
|
result << [css, 'css'] if css.present?
|
|
|
|
result << [js, 'js'] if js.present?
|
|
|
|
|
|
|
|
result.map do |asset, extension|
|
|
|
|
hash = Digest::SHA1.hexdigest asset
|
|
|
|
["#{auto_generated_path}/plugin_#{hash}.#{extension}", asset]
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def automatic_server_assets
|
|
|
|
js = ""
|
|
|
|
|
2015-11-05 11:25:26 -05:00
|
|
|
unless emojis.blank?
|
2015-11-06 09:02:40 -05:00
|
|
|
js << "Discourse.Emoji.addCustomEmojis(function() {" << "\n"
|
|
|
|
|
2015-11-05 11:25:26 -05:00
|
|
|
if @enabled_site_setting.present?
|
|
|
|
js << "if (Discourse.SiteSettings.#{@enabled_site_setting}) {" << "\n"
|
|
|
|
end
|
|
|
|
|
|
|
|
emojis.each do |name, url|
|
|
|
|
js << "Discourse.Dialect.registerEmoji('#{name}', '#{url}');" << "\n"
|
|
|
|
end
|
|
|
|
|
|
|
|
if @enabled_site_setting.present?
|
|
|
|
js << "}" << "\n"
|
|
|
|
end
|
2013-09-19 20:59:17 -04:00
|
|
|
|
2015-11-06 09:02:40 -05:00
|
|
|
js << "});" << "\n"
|
2013-08-23 02:21:52 -04:00
|
|
|
end
|
|
|
|
|
2015-11-06 09:42:16 -05:00
|
|
|
result = []
|
|
|
|
|
2015-11-06 09:02:40 -05:00
|
|
|
if js.present?
|
|
|
|
# Generate an IIFE for the JS
|
|
|
|
asset = "(function(){#{js}})();"
|
|
|
|
hash = Digest::SHA1.hexdigest(asset)
|
2015-11-06 09:42:16 -05:00
|
|
|
result << ["#{auto_generated_path}/plugin_#{hash}.js", asset]
|
2015-11-06 09:02:40 -05:00
|
|
|
end
|
2015-11-06 09:42:16 -05:00
|
|
|
|
|
|
|
result
|
2013-08-23 02:21:52 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
# note, we need to be able to parse seperately to activation.
|
|
|
|
# this allows us to present information about a plugin in the UI
|
|
|
|
# prior to activations
|
|
|
|
def activate!
|
2015-04-27 13:06:53 -04:00
|
|
|
|
|
|
|
if @path
|
|
|
|
# Automatically include all ES6 JS and hbs files
|
|
|
|
root_path = "#{File.dirname(@path)}/assets/javascripts"
|
|
|
|
DiscoursePluginRegistry.register_glob(root_path, 'js.es6')
|
|
|
|
DiscoursePluginRegistry.register_glob(root_path, 'hbs')
|
2015-08-17 15:03:55 -04:00
|
|
|
|
|
|
|
admin_path = "#{File.dirname(@path)}/admin/assets/javascripts"
|
|
|
|
DiscoursePluginRegistry.register_glob(admin_path, 'js.es6', admin: true)
|
|
|
|
DiscoursePluginRegistry.register_glob(admin_path, 'hbs', admin: true)
|
2015-04-27 13:06:53 -04:00
|
|
|
end
|
|
|
|
|
2013-09-11 21:27:13 -04:00
|
|
|
self.instance_eval File.read(path), path
|
2013-08-23 02:21:52 -04:00
|
|
|
if auto_assets = generate_automatic_assets!
|
2015-11-06 09:02:40 -05:00
|
|
|
assets.concat(auto_assets)
|
2013-08-23 02:21:52 -04:00
|
|
|
end
|
2014-12-30 16:29:28 -05:00
|
|
|
|
|
|
|
register_assets! unless assets.blank?
|
|
|
|
|
2015-06-04 15:56:17 -04:00
|
|
|
seed_data.each do |key, value|
|
|
|
|
DiscoursePluginRegistry.register_seed_data(key, value)
|
|
|
|
end
|
|
|
|
|
2015-05-04 10:01:57 -04:00
|
|
|
# TODO: possibly amend this to a rails engine
|
|
|
|
|
|
|
|
# Automatically include assets
|
2014-12-30 16:29:28 -05:00
|
|
|
Rails.configuration.assets.paths << auto_generated_path
|
|
|
|
Rails.configuration.assets.paths << File.dirname(path) + "/assets"
|
2015-08-17 15:03:55 -04:00
|
|
|
Rails.configuration.assets.paths << File.dirname(path) + "/admin/assets"
|
2015-08-27 16:59:36 -04:00
|
|
|
Rails.configuration.assets.paths << File.dirname(path) + "/test/javascripts"
|
2013-08-23 02:21:52 -04:00
|
|
|
|
2015-05-04 10:01:57 -04:00
|
|
|
# Automatically include rake tasks
|
|
|
|
Rake.add_rakelib(File.dirname(path) + "/lib/tasks")
|
|
|
|
|
|
|
|
# Automatically include migrations
|
|
|
|
Rails.configuration.paths["db/migrate"] << File.dirname(path) + "/db/migrate"
|
|
|
|
|
2013-11-19 22:38:21 -05:00
|
|
|
public_data = File.dirname(path) + "/public"
|
|
|
|
if Dir.exists?(public_data)
|
|
|
|
target = Rails.root.to_s + "/public/plugins/"
|
|
|
|
`mkdir -p #{target}`
|
2014-01-17 18:35:52 -05:00
|
|
|
target << name.gsub(/\s/,"_")
|
2013-11-19 22:38:21 -05:00
|
|
|
# TODO a cleaner way of registering and unregistering
|
2013-11-20 01:31:58 -05:00
|
|
|
`rm -f #{target}`
|
|
|
|
`ln -s #{public_data} #{target}`
|
2013-11-19 22:38:21 -05:00
|
|
|
end
|
2013-08-23 02:21:52 -04:00
|
|
|
end
|
|
|
|
|
2014-04-10 02:30:22 -04:00
|
|
|
|
2013-08-25 22:52:36 -04:00
|
|
|
def auth_provider(opts)
|
2013-08-23 02:21:52 -04:00
|
|
|
provider = Plugin::AuthProvider.new
|
2015-09-25 11:29:05 -04:00
|
|
|
|
|
|
|
Plugin::AuthProvider.auth_attributes.each do |sym|
|
2013-08-23 02:21:52 -04:00
|
|
|
provider.send "#{sym}=", opts.delete(sym)
|
|
|
|
end
|
2015-02-04 12:59:18 -05:00
|
|
|
auth_providers << provider
|
2013-08-23 02:21:52 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
# shotgun approach to gem loading, in future we need to hack bundler
|
|
|
|
# to at least determine dependencies do not clash before loading
|
|
|
|
#
|
|
|
|
# Additionally we want to support multiple ruby versions correctly and so on
|
|
|
|
#
|
|
|
|
# This is a very rough initial implementation
|
|
|
|
def gem(name, version, opts = {})
|
|
|
|
gems_path = File.dirname(path) + "/gems/#{RUBY_VERSION}"
|
|
|
|
spec_path = gems_path + "/specifications"
|
|
|
|
spec_file = spec_path + "/#{name}-#{version}.gemspec"
|
|
|
|
unless File.exists? spec_file
|
2014-01-16 20:27:09 -05:00
|
|
|
command = "gem install #{name} -v #{version} -i #{gems_path} --no-document --ignore-dependencies"
|
2013-12-30 17:57:04 -05:00
|
|
|
if opts[:source]
|
|
|
|
command << " --source #{opts[:source]}"
|
|
|
|
end
|
2013-08-23 02:21:52 -04:00
|
|
|
puts command
|
2013-08-25 23:52:15 -04:00
|
|
|
puts `#{command}`
|
2013-08-23 02:21:52 -04:00
|
|
|
end
|
|
|
|
if File.exists? spec_file
|
|
|
|
spec = Gem::Specification.load spec_file
|
|
|
|
spec.activate
|
|
|
|
unless opts[:require] == false
|
2014-02-11 18:53:54 -05:00
|
|
|
require opts[:require_name] ? opts[:require_name] : name
|
2013-08-23 02:21:52 -04:00
|
|
|
end
|
|
|
|
else
|
|
|
|
puts "You are specifying the gem #{name} in #{path}, however it does not exist!"
|
2013-11-19 22:38:21 -05:00
|
|
|
exit(-1)
|
2013-08-23 02:21:52 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-07-02 12:45:17 -04:00
|
|
|
def enabled_site_setting(setting=nil)
|
|
|
|
if setting
|
|
|
|
@enabled_site_setting = setting
|
|
|
|
else
|
|
|
|
@enabled_site_setting
|
|
|
|
end
|
2015-02-04 16:23:39 -05:00
|
|
|
end
|
|
|
|
|
2014-04-10 02:30:22 -04:00
|
|
|
protected
|
|
|
|
|
|
|
|
def register_assets!
|
|
|
|
assets.each do |asset, opts|
|
2014-12-09 14:20:53 -05:00
|
|
|
DiscoursePluginRegistry.register_asset(asset, opts)
|
2014-04-10 02:30:22 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-11-06 09:02:40 -05:00
|
|
|
private
|
|
|
|
|
|
|
|
def write_asset(path, contents)
|
|
|
|
unless File.exists?(path)
|
|
|
|
ensure_directory(path)
|
|
|
|
File.open(path,"w") { |f| f.write(contents) }
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2013-08-23 02:21:52 -04:00
|
|
|
end
|