spontaneous 0.2.0.beta9 → 0.2.0.beta10

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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -0
  3. data/LICENSE +18 -17
  4. data/Rakefile +1 -1
  5. data/application/css/core.css.scss +1 -1
  6. data/application/css/dialogue.css.scss +8 -20
  7. data/application/js/preview.js +28 -7
  8. data/application/js/publish.js +15 -4
  9. data/application/js/top_bar.js +0 -16
  10. data/application/js/views/piece_view.js +1 -1
  11. data/lib/spontaneous/asset/environment.rb +16 -1
  12. data/lib/spontaneous/box.rb +68 -0
  13. data/lib/spontaneous/capistrano/deploy.rb +7 -4
  14. data/lib/spontaneous/capistrano/sync.rb +2 -2
  15. data/lib/spontaneous/cli/init.rb +70 -19
  16. data/lib/spontaneous/cli/init/db.rb +34 -55
  17. data/lib/spontaneous/cli/init/mysql.rb +5 -5
  18. data/lib/spontaneous/cli/init/postgresql.rb +8 -9
  19. data/lib/spontaneous/cli/init/sqlite.rb +1 -2
  20. data/lib/spontaneous/cli/migrate.rb +0 -1
  21. data/lib/spontaneous/cli/site.rb +4 -0
  22. data/lib/spontaneous/collections/entry_set.rb +11 -0
  23. data/lib/spontaneous/data_mapper/content_model.rb +2 -0
  24. data/lib/spontaneous/data_mapper/content_model/serialization.rb +2 -2
  25. data/lib/spontaneous/extensions/array.rb +12 -2
  26. data/lib/spontaneous/field/base.rb +10 -0
  27. data/lib/spontaneous/field/file.rb +32 -2
  28. data/lib/spontaneous/field/image.rb +24 -2
  29. data/lib/spontaneous/field/select.rb +8 -0
  30. data/lib/spontaneous/field/webvideo.rb +8 -0
  31. data/lib/spontaneous/generators/site/config/initializers/fields.rb +55 -0
  32. data/lib/spontaneous/json.rb +3 -2
  33. data/lib/spontaneous/logger.rb +2 -2
  34. data/lib/spontaneous/media/file.rb +3 -3
  35. data/lib/spontaneous/media/image/attributes.rb +72 -6
  36. data/lib/spontaneous/media/image/renderable.rb +53 -20
  37. data/lib/spontaneous/media/store.rb +3 -3
  38. data/lib/spontaneous/media/store/backend.rb +16 -0
  39. data/lib/spontaneous/media/store/cloud.rb +52 -12
  40. data/lib/spontaneous/media/store/local.rb +6 -3
  41. data/lib/spontaneous/model.rb +3 -0
  42. data/lib/spontaneous/model/core/entries.rb +34 -13
  43. data/lib/spontaneous/model/core/entry.rb +3 -1
  44. data/lib/spontaneous/model/page/controllers.rb +1 -2
  45. data/lib/spontaneous/model/page/paths.rb +18 -7
  46. data/lib/spontaneous/output/context.rb +0 -8
  47. data/lib/spontaneous/output/template/renderer.rb +2 -0
  48. data/lib/spontaneous/plugins/application/state.rb +0 -4
  49. data/lib/spontaneous/prototypes/field_prototype.rb +4 -0
  50. data/lib/spontaneous/publishing/immediate.rb +0 -5
  51. data/lib/spontaneous/publishing/progress.rb +2 -2
  52. data/lib/spontaneous/publishing/rerender.rb +1 -4
  53. data/lib/spontaneous/publishing/simultaneous.rb +19 -17
  54. data/lib/spontaneous/publishing/steps.rb +12 -3
  55. data/lib/spontaneous/rack.rb +2 -0
  56. data/lib/spontaneous/rack/asset_server.rb +5 -2
  57. data/lib/spontaneous/rack/back.rb +9 -1
  58. data/lib/spontaneous/rack/back/base.rb +1 -0
  59. data/lib/spontaneous/rack/back/changes.rb +5 -0
  60. data/lib/spontaneous/rack/back/preview.rb +4 -4
  61. data/lib/spontaneous/rack/back/private.rb +11 -0
  62. data/lib/spontaneous/rack/middleware/scope.rb +16 -4
  63. data/lib/spontaneous/rack/page_controller.rb +2 -2
  64. data/lib/spontaneous/rack/public.rb +52 -4
  65. data/lib/spontaneous/sequel.rb +10 -13
  66. data/lib/spontaneous/site.rb +28 -8
  67. data/lib/spontaneous/site/publishing.rb +1 -1
  68. data/lib/spontaneous/site/storage.rb +7 -4
  69. data/lib/spontaneous/tasks/environment.rake +3 -0
  70. data/lib/spontaneous/utils/database/postgres_dumper.rb +23 -2
  71. data/lib/spontaneous/version.rb +1 -1
  72. data/spontaneous.gemspec +7 -12
  73. data/test/fixtures/assets/public1/css/data.css.scss +1 -1
  74. data/test/functional/test_application.rb +15 -0
  75. data/test/functional/test_cli.rb +109 -3
  76. data/test/functional/test_front.rb +108 -10
  77. data/test/test_helper.rb +3 -3
  78. data/test/unit/fields/test_boolean_fields.rb +80 -0
  79. data/test/unit/fields/test_date_fields.rb +47 -0
  80. data/test/unit/fields/test_file_field.rb +210 -0
  81. data/test/unit/{test_images.rb → fields/test_image_fields.rb} +133 -15
  82. data/test/unit/fields/test_location_fields.rb +41 -0
  83. data/test/unit/fields/test_option_fields.rb +61 -0
  84. data/test/unit/fields/test_tag_list_fields.rb +45 -0
  85. data/test/unit/fields/test_text_fields.rb +124 -0
  86. data/test/unit/fields/test_web_video_fields.rb +198 -0
  87. data/test/unit/test_assets.rb +22 -22
  88. data/test/unit/test_boxes.rb +34 -13
  89. data/test/unit/test_changesets.rb +1 -0
  90. data/test/unit/test_extensions.rb +17 -0
  91. data/test/unit/test_fields.rb +20 -643
  92. data/test/unit/test_media.rb +9 -9
  93. data/test/unit/test_page.rb +47 -0
  94. data/test/unit/test_publishing_pipeline.rb +2 -2
  95. data/test/unit/test_serialisation.rb +37 -0
  96. data/test/unit/test_storage.rb +42 -3
  97. metadata +37 -17
@@ -22,9 +22,11 @@ module Spontaneous::Publishing
22
22
  end
23
23
  end
24
24
 
25
- def self.rerender
26
- new do
27
- run :render_revision
25
+ def self.rerender(publishing_steps)
26
+ new([], publishing_steps.progress) do
27
+ RERENDER_STEPS.each do |step|
28
+ run step
29
+ end
28
30
  end
29
31
  end
30
32
 
@@ -140,6 +142,13 @@ module Spontaneous::Publishing
140
142
  :archive_old_revisions
141
143
  ].freeze
142
144
 
145
+ RERENDER_STEPS = [
146
+ :render_revision,
147
+ :copy_assets,
148
+ :copy_static_files,
149
+ :generate_rackup_file
150
+ ].freeze
151
+
143
152
  CORE_PROGRESS = [:browser, :stdout].freeze
144
153
  end
145
154
  end
@@ -15,6 +15,8 @@ module Spontaneous
15
15
  HTTP_LAST_MODIFIED = "Last-Modified".freeze
16
16
  HTTP_NO_CACHE = "max-age=0, must-revalidate, no-cache, no-store".freeze
17
17
 
18
+ SLASH = Spontaneous::Constants::SLASH
19
+
18
20
  NAMESPACE = "/@spontaneous".freeze
19
21
  AUTH_COOKIE = "spontaneous_api_key".freeze
20
22
  SESSION_LIFETIME = 1.year
@@ -3,6 +3,8 @@ module Spontaneous::Rack
3
3
  # header. This wrapper class proxies all requests to a Sprockets enviroment
4
4
  # and adds in a charset setting to the content-type header of all responses
5
5
  class AssetServer
6
+ CONTENT_TYPE = "Content-Type".freeze
7
+
6
8
  def initialize(environment, charset = "UTF-8")
7
9
  @environment, @charset = environment, charset
8
10
  end
@@ -12,8 +14,9 @@ module Spontaneous::Rack
12
14
  end
13
15
 
14
16
  def force_encoding(status, headers, body)
15
- content_type = headers["Content-Type"]
16
- headers.update("Content-Type" => "#{content_type}; charset=#{@charset}")
17
+ if (content_type = headers[CONTENT_TYPE])
18
+ headers.update(CONTENT_TYPE => "#{content_type}; charset=#{@charset}")
19
+ end
17
20
  [status, headers, body]
18
21
  end
19
22
  end
@@ -18,6 +18,7 @@ module Spontaneous
18
18
  autoload :Map, 'spontaneous/rack/back/map'
19
19
  autoload :Page, 'spontaneous/rack/back/page'
20
20
  autoload :Preview, 'spontaneous/rack/back/preview'
21
+ autoload :Private, 'spontaneous/rack/back/private'
21
22
  autoload :Schema, 'spontaneous/rack/back/schema'
22
23
  autoload :Site, 'spontaneous/rack/back/site'
23
24
  autoload :SiteAssets, 'spontaneous/rack/back/site_assets'
@@ -48,6 +49,7 @@ module Spontaneous
48
49
 
49
50
  def self.editing_app(site)
50
51
  ::Rack::Builder.app do
52
+ use ::Rack::ShowExceptions if site.development?
51
53
  use Scope::Edit, site
52
54
  use Transaction, site
53
55
  use ApplicationAssets
@@ -62,6 +64,10 @@ module Spontaneous
62
64
  map("/schema") { run Schema }
63
65
  use Reloader, site
64
66
  use Index
67
+ map("/private") {
68
+ use Scope::Preview, site
69
+ run Private
70
+ }
65
71
  use CSRF::Verification # Everything after this middleware requires a valid CSRF token
66
72
  Back.api_handlers.each do |path, app|
67
73
  map(path) { run app }
@@ -73,6 +79,7 @@ module Spontaneous
73
79
 
74
80
  def self.preview_app(site)
75
81
  ::Rack::Builder.app do
82
+ use ::Rack::ShowExceptions if site.development?
76
83
  use ::Rack::Lint if Spontaneous.development?
77
84
  use Scope::Preview, site
78
85
  use Transaction, site
@@ -84,7 +91,6 @@ module Spontaneous
84
91
  # the preview site.
85
92
  use Authenticate::Preview
86
93
  use CSRF::Header
87
- map("/assets") { run SiteAssets.new }
88
94
  use Spontaneous::Rack::Static, root: Spontaneous.root / "public", urls: %w[/], try: ['.html', 'index.html', '/index.html']
89
95
  use Reloader, site
90
96
  # inject the front controllers into the preview so that this is a
@@ -128,6 +134,8 @@ module Spontaneous
128
134
  end
129
135
  end if site
130
136
 
137
+ map("/assets") { run SiteAssets.new }
138
+
131
139
  map "/media" do
132
140
  use ::Rack::Lint
133
141
  run Spontaneous::Rack::CacheableFile.new(Spontaneous.media_dir)
@@ -4,6 +4,7 @@ module Spontaneous::Rack
4
4
  helpers Helpers
5
5
 
6
6
  set :views, Proc.new { Spontaneous.application_dir + '/views' }
7
+ set :show_exceptions, false
7
8
 
8
9
  def content_model
9
10
  site.model
@@ -15,5 +15,10 @@ module Spontaneous::Rack::Back
15
15
  site.publish_pages(pages)
16
16
  json({})
17
17
  end
18
+
19
+ post '/rerender' do
20
+ site.rerender
21
+ json({})
22
+ end
18
23
  end
19
24
  end
@@ -2,12 +2,12 @@ module Spontaneous::Rack::Back
2
2
  class Preview < Base
3
3
  include Spontaneous::Rack::Public
4
4
 
5
+ set :show_exceptions, proc { Spontaneous.development? || Spontaneous.test? }
6
+
5
7
  # In preview mode we want to find pages even if they're
6
8
  # invisible.
7
- def find_page_by_path(path)
8
- site.model.scope do
9
- site.by_path(path)
10
- end
9
+ def with_scope(&block)
10
+ site.model.scope(&block)
11
11
  end
12
12
 
13
13
  # Redirect to the edit UI if a preview page is being accessed directly
@@ -0,0 +1,11 @@
1
+ module Spontaneous::Rack::Back
2
+ class Private < Base
3
+ include Spontaneous::Rack::Public
4
+
5
+ get '/:id.?:format?' do
6
+ content_for_request { |page|
7
+ _render_page_with_output(page, "html", {})
8
+ }
9
+ end
10
+ end
11
+ end
@@ -41,7 +41,7 @@ module Spontaneous::Rack::Middleware
41
41
  end
42
42
 
43
43
  POWERED_BY = {
44
- "X-Powered-By" => "Spontaneous CMS v#{Spontaneous::VERSION}"
44
+ "X-Powered-By" => "Spontaneous v#{Spontaneous::VERSION} <http://spontaneous.io>"
45
45
  }
46
46
 
47
47
  class Front < Base
@@ -49,18 +49,30 @@ module Spontaneous::Rack::Middleware
49
49
 
50
50
  def initialize(app, site, options = {})
51
51
  super
52
- @renderer = Spontaneous::Output.published_renderer(@site)
53
52
  end
54
53
 
55
54
  def call!(env)
56
55
  status = headers = body = nil
57
- env[RENDERER] = @renderer
58
- env[REVISION] = @site.published_revision
56
+ env[RENDERER] = renderer
57
+ env[REVISION] = revision = @site.published_revision
59
58
  @site.model.with_published(@site) do
60
59
  status, headers, body = @app.call(env)
61
60
  end
62
61
  [status, headers.merge(POWERED_BY), body]
63
62
  end
63
+
64
+ def renderer
65
+ return renderer_for_revision if development?
66
+ @renderer ||= renderer_for_revision
67
+ end
68
+
69
+ def renderer_for_revision
70
+ Spontaneous::Output.published_renderer(@site)
71
+ end
72
+
73
+ def development?
74
+ Spontaneous.development?
75
+ end
64
76
  end
65
77
  end
66
78
  end
@@ -6,7 +6,7 @@ module Spontaneous::Rack
6
6
  class PageController < Sinatra::Base
7
7
  class << self
8
8
  # We wrap Sinatra's route methods in order to do two things:
9
- # 1. To provide a path of '/' when none is given and
9
+ # 1. To provide a path of '*' when none is given and
10
10
  # 2. To register the presence of a handler for each method in order to
11
11
  # correctly respond to the #dynamic? test
12
12
  def get(*args, &bk) __dynamic!(:get, super(*__route_args(args), &bk)) end
@@ -28,7 +28,7 @@ module Spontaneous::Rack
28
28
 
29
29
  def __route_args(args)
30
30
  opts = args.extract_options!
31
- path = String === args.first ? args.first : '/'
31
+ path = (String === args.first) ? args.first : S::Constants::SLASH
32
32
  [path, opts]
33
33
  end
34
34
 
@@ -87,12 +87,52 @@ module Spontaneous
87
87
  end
88
88
 
89
89
  def find_page!(path)
90
+ @controller_path = SLASH
90
91
  @path, @output, @action = parse_path(path)
91
92
  @page = find_page_by_path(@path)
92
93
  end
93
94
 
94
95
  def find_page_by_path(path)
95
- site.by_path(path)
96
+ with_scope { site.by_path(path) || find_page_with_wildcards(path) }
97
+ end
98
+
99
+ # if we get to here it's because the path hasn't been found. This will get called for
100
+ # every request where the request doesn’t resolve to a path found in the db
101
+ # and will always try the site homepage as a last resort. So if you need many dynamic
102
+ # routes to resolve to a single page, e.g. for a single page app, then you just
103
+ # need to accept all those routes in a controller on the class of the site’s homepage
104
+ # and render your SPA template from that, e.g.
105
+ #
106
+ # class Homepage < Page
107
+ # controller do
108
+ # get '/app*' do
109
+ # render
110
+ # end
111
+ # end
112
+ # end
113
+ #
114
+ def find_page_with_wildcards(path)
115
+ parts = path.split('/')
116
+ length = parts.length - 2
117
+ range = (1..length).to_a.reverse
118
+
119
+ # make sure we go all the way back to the site homepage
120
+ try = range.map { |l| parts[0..l].join(SLASH) }.push(SLASH)
121
+ candidate = site.model::Page.where(path: try).order(Sequel.desc(:depth)).first
122
+ return nil if candidate.nil? || !candidate.dynamic?(request.request_method)
123
+
124
+ # don't pass the full path of the request to the controller, just
125
+ # the bit after the candidate page’s path.
126
+ cpath = candidate.path
127
+ @controller_path = path.slice(cpath.length, path.length - cpath.length)
128
+
129
+ # special handling of root, as always so that a controller on the root page that
130
+ # matches '/', e.g. `get '/'` is passed a path that starts with '/'
131
+ if cpath == SLASH
132
+ @controller_path.insert(0, SLASH)
133
+ end
134
+
135
+ candidate
96
136
  end
97
137
 
98
138
  def output(name)
@@ -106,7 +146,7 @@ module Spontaneous
106
146
  def render_get
107
147
  return call_action! if @action
108
148
  if page.dynamic?(request.request_method)
109
- invoke_action { page.process_root_action(site, env.dup, @output) }
149
+ invoke_action { page.process_root_action(site, env_for_action, @output) }
110
150
  else
111
151
  render_page_with_output
112
152
  end
@@ -119,11 +159,15 @@ module Spontaneous
119
159
 
120
160
  return call_action! if @action
121
161
 
122
- invoke_action { page.process_root_action(site, env.dup, @output) }
162
+ invoke_action { page.process_root_action(site, env_for_action, @output) }
123
163
  end
124
164
 
125
165
  def call_action!
126
- invoke_action { @page.process_action(site, action, env.dup, @output) }
166
+ invoke_action { @page.process_action(site, action, env_for_action, @output) }
167
+ end
168
+
169
+ def env_for_action
170
+ env.merge(S::Constants::PATH_INFO => @controller_path)
127
171
  end
128
172
 
129
173
  def invoke_action
@@ -185,6 +229,10 @@ module Spontaneous
185
229
  def not_found!
186
230
  404
187
231
  end
232
+
233
+ def with_scope
234
+ yield
235
+ end
188
236
  end
189
237
  end
190
238
  end
@@ -2,16 +2,13 @@ require "sequel"
2
2
 
3
3
  Sequel.extension :inflector
4
4
 
5
- require 'sequel/plugins/serialization'
6
-
7
- Sequel::Plugins::Serialization.register_format(
8
- :ojson,
9
- lambda { |v| Yajl::Encoder.new.encode(v) },
10
- lambda { |v| Yajl::Parser.new(:symbolize_keys => true).parse(v) }
11
- )
12
- # Sequel::Plugins::Serialization.register_format(
13
- # :ojson,
14
- # lambda { |v| Oj.dump(v) },
15
- # lambda { |v| Oj.load(v, symbol_keys: true) }
16
- # )
17
-
5
+ # See http://sequel.jeremyevans.net/rdoc/classes/Sequel/Timezones.html
6
+ # UTC is more performant than :local (or 'nil' which just fallsback to :local)
7
+ # A basic profiling run gives a 2 x performance improvement of :utc over :local
8
+ # With ~240 rows, timing ::Content.all gives:
9
+ #
10
+ # :utc ~0.04s
11
+ # :local ~0.08s
12
+ #
13
+ # DB timestamps are only shown in the editing UI & could be localized there per-user
14
+ Sequel.default_timezone = :utc
@@ -84,19 +84,31 @@ module Spontaneous
84
84
 
85
85
 
86
86
  def connect_to_database!
87
- db = Sequel.connect(db_settings)
88
- db.logger = logger if config.log_queries
89
- # Improve performance for postgres
90
- db.optimize_model_load = true if db.respond_to?(:optimize_model_load)
91
- self.database = db
87
+ self.database = database_instance(db_settings).tap do |db|
88
+ db.logger = logger if config.log_queries
89
+ # Improve performance for postgres
90
+ db.optimize_model_load = true if db.respond_to?(:optimize_model_load)
91
+ end
92
+ end
93
+
94
+ def database_instance(opts)
95
+ Sequel.connect(opts)
92
96
  end
93
97
 
94
98
  def db_settings
95
- self.config.db = db_config[environment]
99
+ self.config.db ||= db_connection_options(environment)
96
100
  end
97
101
 
98
- def db_config
99
- @db_config ||= YAML.load_file(File.join(paths.expanded(:config).first, "database.yml"))
102
+ def db_connection_options(env)
103
+ (db_config_env || db_config_file[env])
104
+ end
105
+
106
+ def db_config_env
107
+ ENV['DATABASE_URL']
108
+ end
109
+
110
+ def db_config_file
111
+ YAML.load_file(File.join(paths.expanded(:config).first, "database.yml"))
100
112
  end
101
113
 
102
114
  def transaction(&block)
@@ -161,5 +173,13 @@ module Spontaneous
161
173
  return cache_root if path.empty?
162
174
  File.join(cache_root, *path)
163
175
  end
176
+
177
+ def development?
178
+ Spontaneous.development?
179
+ end
180
+
181
+ def inspect
182
+ %[#<Site @root="#@root" @schema=#{@schema.inspect} @paths=#{@paths.inspect} @environment=#{@environment.inspect} @mode=#{@mode.inspect}>]
183
+ end
164
184
  end
165
185
  end
@@ -43,7 +43,7 @@ class Spontaneous::Site
43
43
  end
44
44
 
45
45
  def rerender_steps
46
- Spontaneous::Publishing::Steps.rerender
46
+ Spontaneous::Publishing::Steps.rerender(publish_steps)
47
47
  end
48
48
 
49
49
  def publishing_method
@@ -20,8 +20,11 @@ class Spontaneous::Site
20
20
  module Storage
21
21
  extend Spontaneous::Concern
22
22
 
23
- def storage(mimetype = nil)
24
- storage_for_mimetype(mimetype)
23
+ DEFAULT_STORAGE_NAME = 'default'.freeze
24
+
25
+ def storage(name = nil)
26
+ return storage_backends.first if name.nil?
27
+ storage_backends.detect { |storage| storage.name == name } || default_storage
25
28
  end
26
29
 
27
30
  def storage_for_mimetype(mimetype)
@@ -40,14 +43,14 @@ class Spontaneous::Site
40
43
  storage_backends = []
41
44
  storage_settings = config[:storage] || []
42
45
  storage_settings.each do |name, config|
43
- backend = Spontaneous::Media::Store.create(config)
46
+ backend = Spontaneous::Media::Store.create(name.to_s, config)
44
47
  storage_backends << backend
45
48
  end
46
49
  storage_backends << default_storage
47
50
  end
48
51
 
49
52
  def default_storage
50
- @default_storage ||= Spontaneous::Media::Store::Local.new(Spontaneous.media_dir, '/media', accepts=nil)
53
+ @default_storage ||= Spontaneous::Media::Store::Local.new(DEFAULT_STORAGE_NAME, Spontaneous.media_dir, '/media', accepts=nil)
51
54
  end
52
55
 
53
56
  def file(owner, filename, headers = {})
@@ -0,0 +1,3 @@
1
+
2
+ task :environment do
3
+ end
@@ -18,7 +18,8 @@ module Spontaneous
18
18
  options = [
19
19
  "psql",
20
20
  "--quiet",
21
- option(:password),
21
+ option(:host),
22
+ option(:port),
22
23
  option(:username),
23
24
  database_name
24
25
  ]
@@ -42,7 +43,8 @@ module Spontaneous
42
43
  "--clean",
43
44
  "--no-owner",
44
45
  "--no-privileges",
45
- option(:password),
46
+ option(:host),
47
+ option(:port),
46
48
  option(:username),
47
49
  option(:encoding),
48
50
  option(:exclude_table),
@@ -69,6 +71,14 @@ module Spontaneous
69
71
  @database.opts[:password]
70
72
  end
71
73
 
74
+ def host
75
+ @database.opts[:host]
76
+ end
77
+
78
+ def port
79
+ @database.opts[:port]
80
+ end
81
+
72
82
  def encoding
73
83
  "UTF8"
74
84
  end
@@ -76,6 +86,17 @@ module Spontaneous
76
86
  def exclude_table
77
87
  revision_archive_table
78
88
  end
89
+
90
+ def system(cmd)
91
+ begin
92
+ if (pass = password)
93
+ ENV['PGPASSWORD'] = pass
94
+ end
95
+ super
96
+ ensure
97
+ ENV.delete('PGPASSWORD')
98
+ end
99
+ end
79
100
  end
80
101
  end
81
102
  end