brief 0.0.5 → 1.0.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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/Gemfile.lock +71 -55
  4. data/README.md +149 -48
  5. data/Rakefile +15 -5
  6. data/bin/brief +15 -28
  7. data/brief.gemspec +17 -11
  8. data/examples/blog/brief.rb +54 -0
  9. data/examples/blog/docs/an-intro-to-brief.html.md +9 -0
  10. data/lib/.DS_Store +0 -0
  11. data/lib/brief/briefcase.rb +78 -0
  12. data/lib/brief/cli/change.rb +14 -0
  13. data/lib/brief/cli/init.rb +66 -0
  14. data/{spec/fixtures/generated/project_overview.json → lib/brief/cli/publish.rb} +0 -0
  15. data/lib/brief/cli/write.rb +0 -0
  16. data/lib/brief/configuration.rb +19 -115
  17. data/lib/brief/core_ext.rb +11 -0
  18. data/lib/brief/document/content_extractor.rb +33 -0
  19. data/lib/brief/document/front_matter.rb +20 -0
  20. data/lib/brief/document/rendering.rb +37 -0
  21. data/lib/brief/document.rb +43 -38
  22. data/lib/brief/document_mapper.rb +158 -0
  23. data/lib/brief/dsl.rb +42 -0
  24. data/lib/brief/model/definition.rb +117 -0
  25. data/lib/brief/model.rb +177 -0
  26. data/lib/brief/repository.rb +57 -0
  27. data/lib/brief/version.rb +1 -7
  28. data/lib/brief.rb +35 -40
  29. data/spec/fixtures/example/brief.rb +27 -0
  30. data/spec/fixtures/example/docs/concept.html.md +5 -0
  31. data/spec/fixtures/example/docs/epic.html.md +19 -0
  32. data/spec/fixtures/example/docs/persona.html.md +5 -0
  33. data/spec/fixtures/example/docs/release.html.md +7 -0
  34. data/spec/fixtures/example/docs/resource.html.md +5 -0
  35. data/spec/fixtures/example/docs/user_story.html.md +24 -0
  36. data/spec/fixtures/example/docs/wireframe.html.md +5 -0
  37. data/spec/fixtures/example/models/epic.rb +16 -0
  38. data/spec/lib/brief/briefcase_spec.rb +27 -0
  39. data/spec/lib/brief/document_spec.rb +14 -22
  40. data/spec/lib/brief/dsl_spec.rb +4 -15
  41. data/spec/lib/brief/model_spec.rb +84 -0
  42. data/spec/lib/brief/repository_spec.rb +60 -0
  43. data/spec/spec_helper.rb +12 -14
  44. data/spec/support/test_helpers.rb +0 -0
  45. data/tasks/brief/release.rake +0 -0
  46. metadata +120 -110
  47. data/.gitignore +0 -1
  48. data/Guardfile +0 -5
  49. data/examples/project_overview.md +0 -23
  50. data/lib/brief/cli/commands/config.rb +0 -40
  51. data/lib/brief/cli/commands/publish.rb +0 -27
  52. data/lib/brief/cli/commands/write.rb +0 -27
  53. data/lib/brief/formatters/base.rb +0 -12
  54. data/lib/brief/formatters/github_milestone_rollup.rb +0 -52
  55. data/lib/brief/git.rb +0 -19
  56. data/lib/brief/github/wiki.rb +0 -9
  57. data/lib/brief/github.rb +0 -141
  58. data/lib/brief/github_client/authentication.rb +0 -32
  59. data/lib/brief/github_client/client.rb +0 -86
  60. data/lib/brief/github_client/commands.rb +0 -5
  61. data/lib/brief/github_client/issue_labels.rb +0 -65
  62. data/lib/brief/github_client/issues.rb +0 -22
  63. data/lib/brief/github_client/milestone_issues.rb +0 -13
  64. data/lib/brief/github_client/organization_activity.rb +0 -9
  65. data/lib/brief/github_client/organization_issues.rb +0 -13
  66. data/lib/brief/github_client/organization_repositories.rb +0 -20
  67. data/lib/brief/github_client/organization_users.rb +0 -9
  68. data/lib/brief/github_client/repository_events.rb +0 -8
  69. data/lib/brief/github_client/repository_issue_events.rb +0 -9
  70. data/lib/brief/github_client/repository_issues.rb +0 -8
  71. data/lib/brief/github_client/repository_labels.rb +0 -18
  72. data/lib/brief/github_client/repository_milestones.rb +0 -9
  73. data/lib/brief/github_client/request.rb +0 -181
  74. data/lib/brief/github_client/request_wrapper.rb +0 -121
  75. data/lib/brief/github_client/response_object.rb +0 -50
  76. data/lib/brief/github_client/single_repository.rb +0 -9
  77. data/lib/brief/github_client/user_activity.rb +0 -16
  78. data/lib/brief/github_client/user_gists.rb +0 -9
  79. data/lib/brief/github_client/user_info.rb +0 -9
  80. data/lib/brief/github_client/user_issues.rb +0 -13
  81. data/lib/brief/github_client/user_organizations.rb +0 -9
  82. data/lib/brief/github_client/user_repositories.rb +0 -9
  83. data/lib/brief/github_client.rb +0 -43
  84. data/lib/brief/handlers/base.rb +0 -62
  85. data/lib/brief/handlers/github_issue.rb +0 -41
  86. data/lib/brief/handlers/github_milestone.rb +0 -37
  87. data/lib/brief/handlers/github_wiki.rb +0 -11
  88. data/lib/brief/line.rb +0 -69
  89. data/lib/brief/parser.rb +0 -354
  90. data/lib/brief/publisher/handler_manager.rb +0 -47
  91. data/lib/brief/publisher.rb +0 -142
  92. data/lib/brief/tree.rb +0 -43
  93. data/lib/core_ext.rb +0 -37
  94. data/spec/fixtures/front_end_tutorial.md +0 -33
  95. data/spec/fixtures/generator_dsl_example.rb +0 -22
  96. data/spec/fixtures/project_overview.md +0 -48
  97. data/spec/fixtures/sample.md +0 -19
  98. data/spec/lib/brief/line_spec.rb +0 -11
  99. data/spec/lib/brief/parser_spec.rb +0 -12
@@ -0,0 +1,14 @@
1
+ command "change" do |c|
2
+ c.syntax = "brief change ATTRIBUTE [OPTIONS]"
3
+ c.description = "change attributes of brief documents"
4
+
5
+ c.option "--from", String, "Only apply when the attributes current value matches."
6
+ c.option "--to", String, "Only apply when the attributes current value matches."
7
+ c.option "--files", String, "The files you want to change"
8
+ c.option "--on", String, "alias for --files. The files you want to change"
9
+
10
+ c.action do |args, options|
11
+ puts "Args: #{ args.inspect }"
12
+ puts "Options: #{ options.inspect }"
13
+ end
14
+ end
@@ -0,0 +1,66 @@
1
+ command "init" do |c|
2
+ c.syntax = "brief init NAME [OPTIONS]"
3
+ c.description = "Create a new brief project, aka a briefcase"
4
+
5
+ c.option "--root", String, "The root folder for the new project."
6
+
7
+ c.action do |args, options|
8
+ options.default(:root => Dir.pwd())
9
+
10
+ if path = args.first
11
+ root = Pathname(options.root).join(path)
12
+ end
13
+
14
+ [root, root.join("models"), root.join("docs")].each do |p|
15
+ puts "== folder #{ p.basename } #{ '. exists' if p.exist? }"
16
+ FileUtils.mkdir_p(p) unless p.exist?
17
+ end
18
+
19
+
20
+ if root.join("brief.rb").exist?
21
+ say "== Briefcase config already exists. Skipping."
22
+ else
23
+ say "== Creating config file brief.rb"
24
+ root.join("brief.rb").open("w+") do |fh|
25
+ default_config = <<-EOF
26
+
27
+ config do
28
+ set(:models_path => Pathname(__FILE__).parent.join("models"))
29
+ end
30
+
31
+ define("Post") do
32
+ meta do
33
+ status
34
+ date DateTime, :default => lambda {|post, attr| post.document.created_at }
35
+ end
36
+
37
+ content do
38
+ title "h1"
39
+ has_many :subheadings, "h2"
40
+ end
41
+
42
+ helpers do
43
+ def publish(options={})
44
+ post.set(:status, "published")
45
+ post.save
46
+ end
47
+ end
48
+
49
+ on_status_change(:from => "draft", :to => "published") do |model|
50
+ # Do Something
51
+ # mail_service.send_html_email_campaign(model.to_html)
52
+ end
53
+ end
54
+
55
+ # brief publish posts /path/to/*.html.md
56
+ action "publish posts" do |briefcase, models, options|
57
+ models.each do |post|
58
+ post.publish(options)
59
+ end
60
+ end
61
+ EOF
62
+ fh.write(default_config)
63
+ end
64
+ end
65
+ end
66
+ end
File without changes
@@ -1,134 +1,38 @@
1
- require "singleton"
2
- require "json"
1
+ require 'singleton'
3
2
 
4
3
  module Brief
5
4
  class Configuration
6
5
  include Singleton
7
6
 
8
- DefaultSettings = {
9
- github_username: "",
10
- github_api_token: ""
11
- }
12
-
13
- def self.method_missing meth, *args, &block
7
+ def self.method_missing(meth, *args, &block)
14
8
  if instance.respond_to?(meth)
15
- return instance.send meth, *args, &block
16
- end
17
-
18
- nil
19
- end
20
-
21
- def initialize!
22
- FileUtils.mkdir_p home_config_path.dirname
23
- end
24
-
25
- def method_missing meth, *args, &block
26
- if current.has_key?(meth.to_s)
27
- return current.fetch(meth)
28
- end
29
-
30
- super
31
- end
32
-
33
- def current_github_repository
34
- value = current.github_repository
35
-
36
- return value if value
37
-
38
- if value.nil?
39
- if guess = Brief::Git.current_github_repository
40
- set "github_repository", guess
41
- return guess
42
- end
9
+ instance.send(meth, *args, &block)
10
+ else
11
+ super
43
12
  end
44
-
45
- current.github_repository
46
13
  end
47
14
 
48
15
  def current
49
- @current ||= begin
50
- home_config.merge(cwd_config)
51
- end
16
+ @current ||= {
17
+ docs_path: "docs",
18
+ models_path: "models"
19
+ }.to_mash
52
20
  end
53
21
 
54
- def current_config
55
- cwd_config_path.exist? ? cwd_config : home_config
22
+ def set(attribute, value=nil)
23
+ current[attribute] = value
24
+ self
56
25
  end
57
26
 
58
- def set setting, value, persist=true
59
- current_config[setting] = value
60
- save! if persist == true
61
- value
62
- end
63
-
64
- def unset setting, persist=true
65
- current_config.delete(setting)
66
- save! if persist == true
67
- end
68
-
69
- def current
70
- Hashie::Mash.new(home_config.merge(cwd_config).merge(applied_config))
71
- end
72
-
73
- def apply_config_from_path path
74
- path = Pathname(path)
75
- parsed = JSON.parse(path.read) rescue {}
76
- applied_config.merge!(parsed)
77
- nil
78
- end
79
-
80
- def save!
81
- save_home_config
82
- save_cwd_config
83
- end
84
-
85
- def save_cwd_config
86
- return nil unless cwd_config_path.exist?
87
-
88
- File.open(cwd_config_path,'w+') do |fh|
89
- fh.write JSON.generate(cwd_config.to_hash)
27
+ def method_missing(meth, *args, &block)
28
+ if current.respond_to?(meth) && current.key?(meth)
29
+ current.send(meth, *args, &block)
30
+ else
31
+ # swallow invalid method calls in production
32
+ super if ENV['BRIEF_DEBUG_MODE']
33
+ nil
90
34
  end
91
35
  end
92
36
 
93
- def save_home_config
94
- File.open(home_config_path,'w+') do |fh|
95
- fh.write JSON.generate(home_config.to_hash)
96
- end
97
- end
98
-
99
- # Applied config is configuration values passed in context
100
- # usually from the cli, but also in the unit tests
101
- def applied_config
102
- @applied_config ||= {}
103
- end
104
-
105
- def cwd_config
106
- @cwd_config ||= begin
107
- (cwd_config_path.exist? rescue false) ? JSON.parse(cwd_config_path.read) : {}
108
- rescue
109
- {}
110
- end
111
- end
112
-
113
- def home_config
114
- @home_config ||= begin
115
- (home_config_path.exist? rescue false) ? JSON.parse(home_config_path.read) : {}
116
- rescue
117
- {}
118
- end
119
- end
120
-
121
- def home_folder
122
- Pathname(ENV['HOME']).join('.brief')
123
- end
124
-
125
- def home_config_path
126
- Pathname(ENV['HOME']).join('.brief','config.json')
127
- end
128
-
129
- def cwd_config_path
130
- Pathname(Dir.pwd).join('.briefrc')
131
- end
132
37
  end
133
38
  end
134
-
@@ -0,0 +1,11 @@
1
+ class Hash
2
+ def to_mash
3
+ Hashie::Mash.new(self)
4
+ end
5
+ end
6
+
7
+ class String
8
+ def to_pathname
9
+ Pathname(self)
10
+ end
11
+ end
@@ -0,0 +1,33 @@
1
+ module Brief
2
+ class Document::ContentExtractor
3
+ def initialize(model_type, document)
4
+ @model_type = model_type
5
+ @document = document
6
+ end
7
+
8
+ def document
9
+ @document
10
+ end
11
+
12
+ def model_class
13
+ Brief::Model.for_type(@model_type)
14
+ end
15
+
16
+ def attribute_set
17
+ model_class.definition.content_schema.attributes
18
+ end
19
+
20
+ def respond_to?(meth)
21
+ attribute_set.key?(meth) || super
22
+ end
23
+
24
+ def method_missing(meth, *args, &block)
25
+ if settings = attribute_set.fetch(meth, nil)
26
+ if settings.args.length == 1 && settings.args.first.is_a?(String)
27
+ selector = settings.args.first
28
+ document.css(selector).try(:text)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,20 @@
1
+ module Brief
2
+ class Document
3
+ module FrontMatter
4
+ extend ActiveSupport::Concern
5
+
6
+ def frontmatter
7
+ @frontmatter || load_frontmatter
8
+ end
9
+
10
+ protected
11
+
12
+ def load_frontmatter
13
+ if content =~ /^(---\s*\n.*?\n?)^(---\s*$\n?)/m
14
+ self.content = content[($1.size + $2.size)..-1]
15
+ @frontmatter = YAML.load($1).to_mash
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,37 @@
1
+ module Brief
2
+ class Document
3
+ module Rendering
4
+ extend ActiveSupport::Concern
5
+
6
+ def to_html
7
+ self.class.renderer.render(content)
8
+ end
9
+
10
+ def css(*args, &block)
11
+ parser.send(:css, *args, &block)
12
+ end
13
+
14
+ def at(*args, &block)
15
+ parser.send(:at, *args, &block)
16
+ end
17
+
18
+ def parser
19
+ @parser ||= Nokogiri::HTML.fragment(to_html)
20
+ end
21
+
22
+ module ClassMethods
23
+ def renderer
24
+ @renderer ||= begin
25
+ r = ::Redcarpet::Render::HTML.new(:tables => true,
26
+ :autolink => true,
27
+ :gh_blockcode => true,
28
+ :fenced_code_blocks => true,
29
+ :footnotes => true)
30
+
31
+ ::Redcarpet::Markdown.new(r)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,68 +1,73 @@
1
-
2
1
  module Brief
3
2
  class Document
4
- attr_reader :content, :options
3
+ include Brief::Document::Rendering
4
+ include Brief::Document::FrontMatter
5
+
6
+ attr_accessor :path, :content, :frontmatter
5
7
 
6
- def initialize(options)
8
+ def initialize(path, options={})
9
+ @path = Pathname(path)
7
10
  @options = options
8
11
 
9
- case
10
- when options.is_a?(String)
11
- @content = options
12
- when options.is_a?(Pathname)
13
- @content = options.read
14
- when options.is_a?(Hash)
15
- @content = options[:path].read
12
+ if self.path.exist?
13
+ content
14
+ load_frontmatter
16
15
  end
16
+
17
+ self.model_class.try(:models).try(:<<, to_model) unless model_instance_registered?
17
18
  end
18
19
 
19
- def write using
20
- using.run_before_hooks(:write) do
21
- write
22
- end
20
+ def content
21
+ @content ||= path.read
23
22
  end
24
23
 
25
- def publish using=nil
26
- using ||= publisher
24
+ def extract_content(*args)
25
+ options = args.extract_options!
26
+ args = options[:args] if options.is_a?(Hash) && options.key?(:args)
27
27
 
28
- document = self
29
- using.run_before_hooks(:publish) do
30
- process(document)
28
+ case
29
+ when args.length == 1 && args.first.is_a?(String)
30
+ css(args.first).try(:text).to_s
31
31
  end
32
32
  end
33
33
 
34
- def publisher
35
- @publisher ||= Brief::Publisher.find(options[:publisher] || "default")
34
+ def data
35
+ frontmatter
36
36
  end
37
37
 
38
- def method_missing meth, *args, &block
39
- if parser.respond_to?(meth)
40
- return parser.send(meth, *args, &block)
41
- end
42
-
43
- super
38
+ def extension
39
+ path.extname
44
40
  end
45
41
 
46
- def checksum
47
- @checksum ||= Digest::MD5.hexdigest(content)
42
+ def to_model
43
+ model_class.new(data.to_hash.merge(path: path, document: self)) if model_class
48
44
  end
49
45
 
50
- def parser
51
- @parser ||= Brief::Parser.new(content, checksum: checksum)
46
+ def model_class
47
+ @model_class || ((data && data.type) && Brief::Model.for_type(data.type))
52
48
  end
53
49
 
54
-
55
- def settings
56
- parser.front_matter
50
+ # Each model class tracks the instances of the models created
51
+ # and ensures that there is a 1-1 relationship between a document path
52
+ # and the model.
53
+ def model_instance_registered?
54
+ self.model_class && self.model_class.models.any? do |model|
55
+ model.path == self.path
56
+ end
57
57
  end
58
58
 
59
- def elements
60
- parser.send(:elements)
59
+ def respond_to?(method)
60
+ super || data.respond_to?(method) || data.key?(method)
61
61
  end
62
62
 
63
- def tree
64
- parser.send(:tree)
63
+ def method_missing(meth, *args, &block)
64
+ if data.respond_to?(meth)
65
+ data.send(meth, *args, &block)
66
+ else
67
+ super
68
+ end
65
69
  end
66
70
 
67
71
  end
68
72
  end
73
+
@@ -0,0 +1,158 @@
1
+ module Brief::DocumentMapper
2
+ OPERATOR_MAPPING = {
3
+ 'equal' => :==,
4
+ 'eq' => :==,
5
+ 'neq' => :!=,
6
+ 'not_equal' => :!=,
7
+ 'gt' => :>,
8
+ 'gte' => :>=,
9
+ 'in' => :in?,
10
+ 'include' => :include?,
11
+ 'lt' => :<,
12
+ 'lte' => :<=
13
+ }
14
+
15
+ VALID_OPERATORS = OPERATOR_MAPPING.keys
16
+
17
+ class Selector
18
+ attr_reader :attribute, :operator
19
+
20
+ def initialize(opts = {})
21
+ unless Brief::DocumentMapper::VALID_OPERATORS.include?(opts[:operator])
22
+ raise 'Operator not supported'
23
+ end
24
+
25
+ @attribute, @operator = opts[:attribute], opts[:operator]
26
+ end
27
+ end
28
+
29
+ class Query
30
+ attr_reader :model
31
+
32
+ def initialize(model)
33
+ @model = model
34
+ @where = {}
35
+ end
36
+
37
+ def where(constraints_hash)
38
+ selector_hash = constraints_hash.reject { |key, value| !key.is_a? Selector }
39
+ symbol_hash = constraints_hash.reject { |key, value| key.is_a? Selector }
40
+ symbol_hash.each do |attribute, value|
41
+ selector = Selector.new(:attribute => attribute, :operator => 'equal')
42
+ selector_hash.update({ selector => value })
43
+ end
44
+ @where.merge! selector_hash
45
+ self
46
+ end
47
+
48
+ def order_by(field)
49
+ @order_by = field.is_a?(Symbol) ? {field => :asc} : field
50
+ self
51
+ end
52
+
53
+ def offset(number)
54
+ @offset = number
55
+ self
56
+ end
57
+
58
+ def limit(number)
59
+ @limit = number
60
+ self
61
+ end
62
+
63
+ def first
64
+ self.all.first
65
+ end
66
+
67
+ def last
68
+ self.all.last
69
+ end
70
+
71
+ def run_query
72
+ if query_is_empty?
73
+ select
74
+ else
75
+ model.select do |obj|
76
+ match = true
77
+
78
+ @where.each do |selector, value|
79
+ if obj.respond_to?(selector.attribute)
80
+ test_value = obj.send(selector.attribute)
81
+ operator = OPERATOR_MAPPING[selector.operator]
82
+ match = false unless test_value.send(operator, value)
83
+ else
84
+ match = false
85
+ end
86
+ end
87
+
88
+ match
89
+ end
90
+ end
91
+ end
92
+
93
+ def all
94
+ results = run_query
95
+
96
+ if @order_by
97
+ order_by_attr = @order_by.keys.first
98
+ direction = @order_by.values.first
99
+
100
+ results.select! do |result|
101
+ result.respond_to?(order_by_attr)
102
+ end
103
+
104
+ results.sort_by! do |result|
105
+ result.send(order_by_attr)
106
+ end
107
+
108
+ results.reverse! if direction == :desc
109
+ end
110
+
111
+ if @offset.present?
112
+ results = results.last([results.size - @offset, 0].max)
113
+ end
114
+
115
+ if @limit.present?
116
+ results = results.first(@limit)
117
+ end
118
+
119
+ results
120
+ end
121
+
122
+ def to_a
123
+ all
124
+ end
125
+
126
+ def query_is_empty?
127
+ @where.empty? && @limit.nil? && @order_by.nil?
128
+ end
129
+
130
+ def inspect
131
+ "Query: #{ @where.map {|k,v| "#{k.attribute} #{k.operator} #{v}" }}"
132
+ end
133
+
134
+ def method_missing(meth, *args, &block)
135
+ if all.respond_to?(meth)
136
+ all.send(meth, *args, &block)
137
+ else
138
+ super
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ class Symbol
145
+ Brief::DocumentMapper::VALID_OPERATORS.each do |operator|
146
+ class_eval <<-OPERATORS
147
+ def #{operator}
148
+ Brief::DocumentMapper::Selector.new(:attribute => self, :operator => '#{operator}')
149
+ end
150
+ OPERATORS
151
+ end
152
+
153
+ unless method_defined?(:"<=>")
154
+ def <=>(other)
155
+ self.to_s <=> other.to_s
156
+ end
157
+ end
158
+ end
data/lib/brief/dsl.rb CHANGED
@@ -0,0 +1,42 @@
1
+ module Brief
2
+ module DSL
3
+ extend ActiveSupport::Concern
4
+
5
+ def config(options={}, &block)
6
+ Brief::Configuration.instance.load_options(options) unless options.nil? || options.empty?
7
+ Brief::Configuration.instance.instance_eval(&block) if block_given?
8
+ end
9
+
10
+ def define(*args, &block)
11
+ definition = Brief::Model::Definition.new(args.first, args.extract_options!)
12
+ definition.instance_eval(&block) if block_given?
13
+ definition.validate!
14
+ end
15
+
16
+ def action(identifier, options={}, &block)
17
+ Object.class.class_eval do
18
+ command "#{identifier}" do |c|
19
+ c.syntax = "brief #{identifier}"
20
+ c.description = "run the #{identifier} command"
21
+
22
+ c.action do |args, opts|
23
+ briefcase = Brief.case
24
+
25
+ path_args = args.select {|arg| arg.is_a?(String) && arg.match(/\.md$/) }
26
+
27
+ path_args.select! do |arg|
28
+ path = briefcase.repository.root.join(arg)
29
+ path.exist?
30
+ end
31
+
32
+ path_args.map! {|p| briefcase.repository.root.join(p) }
33
+
34
+ models = path_args.map {|path| Brief::Document.new(path) }.map(&:to_model)
35
+
36
+ block.call(Brief.case, models, opts)
37
+ end
38
+ end rescue nil
39
+ end
40
+ end
41
+ end
42
+ end