gimdb 0.0.1

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.
data/src/gimdb.rb ADDED
@@ -0,0 +1,379 @@
1
+ begin
2
+ require 'gtk2'
3
+ rescue LoadError => e
4
+ puts "Error: #{e.to_s}"
5
+ puts ""
6
+ puts "You must install 'gtk2' to run this program."
7
+ puts "If you are using Debian/GNU Linux you can install it with:"
8
+ puts ""
9
+ puts " apt-get install libgnome2-ruby"
10
+ puts ""
11
+ exit -1
12
+ end
13
+ begin
14
+ require 'libglade2'
15
+ rescue LoadError => e
16
+ puts "Error: #{e.to_s}"
17
+ puts ""
18
+ puts "You must install 'libglade2' to run this program."
19
+ puts "If you are using Debian/GNU Linux you can install it with:"
20
+ puts ""
21
+ puts " apt-get install libglade2-ruby"
22
+ puts ""
23
+ exit -1
24
+ end
25
+ require 'lib/imdb'
26
+ require 'src/controller'
27
+ require 'src/movie_box'
28
+
29
+
30
+ class GimdbGlade
31
+ include GetText
32
+
33
+ attr :glade
34
+
35
+ def initialize(path_or_data, root = nil, domain = $DOMAIN, localedir = $LOCALEDIR, flag = GladeXML::FILE)
36
+ bindtextdomain(domain, localedir, nil, 'UTF-8')
37
+ @glade = GladeXML.new(path_or_data, root, domain, localedir, flag) { |handler| method(handler) }
38
+ @searcher = IMDB.new
39
+ @movies = []
40
+ setting_up
41
+ end
42
+
43
+
44
+ private
45
+
46
+
47
+ def setting_up
48
+ # Get widgets from glade xml file
49
+ @window = @glade.get_widget('window')
50
+ @sidebar = @glade.get_widget('sidebar')
51
+ @users_menu_item = @glade.get_widget('users_menu_item')
52
+ @entry_title = @glade.get_widget('entry_title')
53
+ @spin_year_from = @glade.get_widget('spin_year_from')
54
+ @spin_year_to = @glade.get_widget('spin_year_to')
55
+ @combo_rating_from = @glade.get_widget('combo_rating_from')
56
+ @combo_rating_to = @glade.get_widget('combo_rating_to')
57
+ @b_search = @glade.get_widget('b_search')
58
+ @combo_sort = @glade.get_widget('combo_sort')
59
+ @toggle_sort = @glade.get_widget('toggle_sort')
60
+ @check_hide_seen = @glade.get_widget('check_hide_seen')
61
+ @check_only_see = @glade.get_widget('check_only_see')
62
+ @label_status = @glade.get_widget('label_status')
63
+ @progress = @glade.get_widget('progress')
64
+ @image_connection = @glade.get_widget('image_connection')
65
+ @image_spinner = @glade.get_widget('image_spinner')
66
+ @scrolled = @glade.get_widget('scrolled')
67
+ @vbox_movies = Gtk::VBox.new
68
+ @dialog_users = @glade.get_widget('dialog_users')
69
+ @entry_user = @glade.get_widget('entry_user')
70
+ @combo_del_users = @glade.get_widget('combo_del_users')
71
+ @table_combo = @glade.get_widget('table_combo')
72
+ @check_genres_all = @glade.get_widget('check_genres_all')
73
+
74
+ @genres = [
75
+ :action,:adventure,:animation,:biography,:comedy,
76
+ :crime,:documentary,:drama,:family,:fantasy,:film_noir,
77
+ :game_show,:history,:horror,:music,:musical,:mystery,:news,
78
+ :romance,:sci_fi,:sport,:thriller,:war,:western
79
+ ]
80
+ @genres.each do |genre|
81
+ instance_variable_set("@check_genres_#{genre}", @glade.get_widget("check_genres_#{genre}")).signal_connect('clicked') do
82
+ @check_genres_all.active = false
83
+ end
84
+ end
85
+
86
+ # Some stuffs
87
+ @users = User.find(:all, :conditions => 'selected = 1')
88
+ @all_users = User.all
89
+ build_users_menu
90
+ @spin_year_to.value = Time.now.year.to_i
91
+ @combo_rating_from.active = 0
92
+ @combo_rating_to.active = 9
93
+ @combo_sort.active = 0
94
+ @image_spinner.pixbuf_animation = Gdk::PixbufAnimation.new('data/icons/spinner16x16.gif')
95
+ @scrolled.add_with_viewport(@vbox_movies)
96
+ @scrolled.vscrollbar.signal_connect('value-changed') do |s|
97
+ x = (s.adjustment.upper * 90.0)/100.0
98
+ vadj = s.value + s.adjustment.page_size
99
+ if (vadj > x && vadj > @vadj && @b_search.sensitive?)
100
+ Thread.new{get_more_movies} if @b_search.sensitive?
101
+ #get_more_movies if @b_search.sensitive?
102
+ end
103
+ @vadj = vadj
104
+ end
105
+ @vbox_movies.border_width = 10
106
+ @vbox_movies.spacing = 10
107
+
108
+ # Window startup
109
+ @window.signal_connect('delete_event') { Gtk.main_quit }
110
+ @window.show_all
111
+ @progress.hide
112
+ @label_status.hide
113
+ @image_spinner.hide
114
+ @image_connection.hide
115
+ end
116
+
117
+
118
+ def build_options
119
+ options = {}
120
+ options[:offline] = @offline
121
+ options[:title] = @entry_title.text unless @entry_title.text.blank?
122
+ options[:release_date] = "#{@spin_year_from.value.to_i},#{@spin_year_to.value.to_i}"
123
+ rating_from = @combo_rating_from.active_text
124
+ rating_to = @combo_rating_to.active_text
125
+ if rating_from != '1' && rating_to != '10'
126
+ options[:user_rating] = "#{rating_from},#{rating_to}"
127
+ end
128
+ unless @check_genres_all.active?
129
+ options[:genres] = ''
130
+ @genres.each do |genre|
131
+ options[:genres] += "#{genre}," if instance_variable_get("@check_genres_#{genre}").active?
132
+ end
133
+ options[:genres].chop!
134
+ end
135
+ case @combo_sort.active
136
+ when 0
137
+ sort = 'moviemeter'
138
+ when 1
139
+ sort = 'alpha'
140
+ when 2
141
+ sort = 'user_rating'
142
+ when 3
143
+ sort = 'num_votes'
144
+ when 4
145
+ sort = 'runtime'
146
+ when 5
147
+ sort = 'year'
148
+ end
149
+ options[:sort] = sort + (@toggle_sort.active? ? ',DESC' : '')
150
+ return options
151
+ end
152
+
153
+
154
+ def searching(state)
155
+ if state
156
+ @b_search.sensitive = false
157
+ update_progress_bar(0.0, 0.0, _('Searching'))
158
+ @progress.show
159
+ @label_status.show
160
+ @image_spinner.show
161
+ else
162
+ @b_search.sensitive = true
163
+ @progress.hide
164
+ @label_status.hide
165
+ @image_spinner.hide
166
+ end
167
+ end
168
+
169
+
170
+ def update_progress_bar(step, max, text = nil)
171
+ max = 80000 if max.nil? || max == 0
172
+ fraction = step.to_f / max.to_f
173
+ fraction = 1.0 if fraction > 1.0
174
+ @progress.fraction = fraction
175
+ @label_status.text = _(text) + '...' unless text.nil?
176
+ end
177
+
178
+
179
+ def get_movies(kind = nil)
180
+ if @b_search.sensitive?
181
+ clear_movies_list
182
+ searching(true)
183
+ if kind.nil?
184
+ @movies = Controller::process_info(@searcher, build_options) do |step, max, text|
185
+ update_progress_bar(step, max, text)
186
+ end
187
+ else
188
+ @movies = Movie.get_kind(@users, kind)
189
+ end
190
+ @index = 0
191
+ update_movies_list
192
+ searching(false)
193
+ end
194
+ end
195
+
196
+
197
+ def get_more_movies
198
+ if @b_search.sensitive?
199
+ searching(true)
200
+ @movies += Controller::process_info(@searcher, :next => true, :offline => @offline) do |step, max, text|
201
+ update_progress_bar(step, max, text)
202
+ end
203
+ update_movies_list
204
+ searching(false)
205
+ end
206
+ end
207
+
208
+
209
+ def clear_movies_list
210
+ @scrolled.each { |child| @scrolled.remove(child) }
211
+ @vbox_movies = Gtk::VBox.new
212
+ @vbox_movies.border_width = 10
213
+ @vbox_movies.spacing = 10
214
+ @scrolled.add_with_viewport(@vbox_movies)
215
+ @scrolled.vscrollbar.adjustment.value = 0
216
+ end
217
+
218
+
219
+ def update_movies_list
220
+ @progress.fraction = 0
221
+ unless @index.nil?
222
+ display_movies = @movies[@index..-1]
223
+ display_movies.each_with_index do |m, i|
224
+ if ((!@check_hide_seen.active? || (m.get_users(:seen) & @users).empty?) &&
225
+ (!@check_only_see.active? || !(m.get_users(:to_see) & @users).empty?))
226
+ @vbox_movies.pack_start(GtkGimdb::MovieBox.new(m, @users), false)
227
+ @vbox_movies.pack_start(Gtk::HSeparator.new, false)
228
+ end
229
+ update_progress_bar(i, display_movies.size - 1, 'Building movie boxes')
230
+ end
231
+ @index = @movies.size
232
+ @vbox_movies.show_all
233
+ end
234
+ end
235
+
236
+
237
+ def build_users_menu
238
+ submenu = Gtk::Menu.new
239
+ @all_users.sort{|x,y| x.name <=> y.name}.each do |u|
240
+ m = Gtk::CheckMenuItem.new(u.name)
241
+ m.active = true if @users.include?(u)
242
+ submenu.append(m)
243
+ m.signal_connect('toggled') do
244
+ if m.active?
245
+ u.selected = 1
246
+ u.save!
247
+ @users << u
248
+ else
249
+ u.selected = 0
250
+ u.save!
251
+ @users.delete(u)
252
+ end
253
+ @users.sort!{|x,y| x.name <=> y.name}
254
+ end
255
+ end
256
+ @users_menu_item.submenu = submenu
257
+ @users_menu_item.show_all
258
+ end
259
+
260
+
261
+ ############
262
+ ### Events ###
263
+ ############
264
+
265
+
266
+ def on_search_clicked(widget, arg = nil)
267
+ Thread.new{get_movies} if @b_search.sensitive?
268
+ #get_movies if @b_search.sensitive?
269
+ end
270
+
271
+ def on_key_press(widget, arg = nil)
272
+ on_search_clicked(widget) if arg.keyval == Gdk::Keyval::GDK_Return
273
+ end
274
+
275
+ def on_get_more_movies_clicked(widget, arg = nil)
276
+ Thread.new{get_more_movies} if @b_search.sensitive?
277
+ #get_more_movies if @b_search.sensitive?
278
+ end
279
+
280
+ def on_show_to_see_clicked(widget, arg = nil)
281
+ Thread.new{get_movies(:to_see)}
282
+ end
283
+
284
+ def on_show_seen_clicked(widget, arg = nil)
285
+ Thread.new{get_movies(:seen)}
286
+ end
287
+
288
+ def on_show_favourites_clicked(widget, arg = nil)
289
+ Thread.new{get_movies(:favourites)}
290
+ end
291
+
292
+ def on_clean_clicked(widget, arg = nil)
293
+ @entry_title.text = ''
294
+ @spin_year_from.value = 1970
295
+ @spin_year_to.value = Time.now.year.to_i
296
+ @combo_rating_from.active = 0
297
+ @combo_rating_to.active = 9
298
+ @combo_sort.active = 0
299
+ @genres.each do |genre|
300
+ instance_variable_get("@check_genres_#{genre}").active = false
301
+ @check_genres_all.active = true
302
+ end
303
+ end
304
+
305
+ def on_work_offline_clicked(widget, arg = nil)
306
+ @offline = !widget.active?
307
+ if @offline
308
+ @window.title += ' (offline)'
309
+ @image_connection.show
310
+ else
311
+ @window.title = @window.title.gsub(' (offline)', '')
312
+ @image_connection.hide
313
+ end
314
+ end
315
+
316
+ def on_quit_clicked(widget, arg = nil)
317
+ Gtk.main_quit
318
+ end
319
+
320
+ def on_show_sidebar(widget, arg = nil)
321
+ widget.active? ? @sidebar.show : @sidebar.hide
322
+ end
323
+
324
+ def on_manage_users_clicked(widget, arg = nil)
325
+ @combo_del_users = Gtk::ComboBox.new
326
+ @table_combo.attach(@combo_del_users, 0,1, 1,2)
327
+ @all_users.sort{|x,y| x.name <=> y.name}.each do |u|
328
+ @combo_del_users.append_text(u.name)
329
+ end
330
+ @combo_del_users.active = 0
331
+ @dialog_users.show_all
332
+ end
333
+
334
+ def on_add_users_clicked(widget, arg = nil)
335
+ unless @entry_user.text.empty?
336
+ u = User.new(:name => @entry_user.text)
337
+ begin
338
+ u.save
339
+ @all_users << u
340
+ build_users_menu
341
+ @combo_del_users.append_text(u.name)
342
+ @combo_del_users.active = 0
343
+ @entry_user.text = ''
344
+ @label_status.text = _('New user added')
345
+ @label_status.show
346
+ # @dialog_users.hide
347
+ rescue
348
+ nil
349
+ end
350
+ end
351
+ end
352
+
353
+ def on_del_users_clicked(widget, arg = nil)
354
+ name = @combo_del_users.active_text
355
+ u = User.find_by_name(name)
356
+ unless u.nil?
357
+ u.destroy
358
+ @all_users.delete(u)
359
+ build_users_menu
360
+ @combo_del_users.remove_text(@combo_del_users.active)
361
+ @label_status.text = _('User deleted')
362
+ @label_status.show
363
+ # @combo_del_users.active = 0
364
+ end
365
+ end
366
+
367
+ def on_select_all_users_clicked(widget, arg = nil)
368
+ @users_menu_item.submenu.children.each do |c|
369
+ c.active = !c.active?
370
+ end
371
+ @dialog_users.hide
372
+ end
373
+
374
+ def on_close_manage_users_clicked(widget, arg = nil)
375
+ @dialog_users.hide
376
+ @label_status.hide
377
+ end
378
+
379
+ end
data/src/model.rb ADDED
@@ -0,0 +1,135 @@
1
+ require 'rubygems'
2
+ require 'active_record'
3
+ require 'etc'
4
+
5
+
6
+ $GIMDB_PATH = "#{Etc.getpwuid.dir}/.gimdb"
7
+ Dir.mkdir($GIMDB_PATH) unless File.exist?($GIMDB_PATH)
8
+ Dir.mkdir("#{$GIMDB_PATH}/posters") unless File.exist?("#{$GIMDB_PATH}/posters")
9
+
10
+
11
+ ActiveRecord::Base.establish_connection(
12
+ :adapter => "sqlite3",
13
+ :database => "#{$GIMDB_PATH}/db.sqlite3"
14
+ )
15
+
16
+
17
+ if(!ActiveRecord::Base.connection.tables.include?('movies') ||
18
+ !ActiveRecord::Base.connection.tables.include?('users') ||
19
+ !ActiveRecord::Base.connection.tables.include?('populars'))
20
+ ActiveRecord::Schema.define do
21
+ create_table :movies do |t|
22
+ t.string :code, :unique => true, :null => false
23
+ t.string :title, :null => false
24
+ t.string :image_url
25
+ t.string :image_path
26
+ t.integer :year, :limit => 4
27
+ t.integer :votes
28
+ t.float :rating
29
+ t.text :outline
30
+ t.string :credit
31
+ t.string :genre
32
+ t.float :runtime
33
+ t.datetime :updated_at, :null => false, :default => Time.now
34
+ end
35
+
36
+ create_table :users do |t|
37
+ t.string :name, :unique => true, :null => :false
38
+ t.integer :selected, :limit => 1, :default => 0
39
+ end
40
+
41
+ create_table :populars, :id => false do |t|
42
+ t.references :movie
43
+ t.references :user
44
+ t.integer :kind, :null => false, :limit => 3
45
+ end
46
+
47
+ add_index :movies, :id, :unique
48
+ add_index :populars, [:movie_id, :user_id, :kind], :unique
49
+ end
50
+ end
51
+
52
+
53
+ class Popular < ActiveRecord::Base
54
+ belongs_to :movie
55
+ belongs_to :user
56
+ end
57
+
58
+
59
+ class Movie < ActiveRecord::Base
60
+ has_many :populars, :dependent => :delete_all
61
+ has_many :users, :through => :populars
62
+
63
+ def get_users(what = :to_see)
64
+ code = Movie.get_code(what)
65
+ unless code.nil?
66
+ pops = Popular.find(:all, :conditions => "movie_id = #{self.id} AND kind = #{code}")
67
+ return [] if pops.nil?
68
+ return pops.collect{|pop| pop.user}
69
+ else
70
+ return []
71
+ end
72
+ end
73
+
74
+ def set_user(user, what = :to_see)
75
+ code = Movie.get_code(what)
76
+ unless code.nil?
77
+ pop = Popular.new(:movie => self, :user => user, :kind => code)
78
+ self.populars << pop
79
+ end
80
+ end
81
+
82
+ def remove_user(user, what = :to_see)
83
+ code = Movie.get_code(what)
84
+ unless code.nil?
85
+ sql = "delete from populars where movie_id = #{self.id} AND user_id = #{user.id} AND kind = #{code}"
86
+ ActiveRecord::Base.connection.execute(sql)
87
+ end
88
+ end
89
+
90
+ def self.get_list(options)
91
+ @start = options[:start] || 1
92
+ c = ''
93
+ c += " AND title LIKE '%#{options[:title]}%'" if options[:title]
94
+ if options[:release_data] && options[:release_data].include?(',')
95
+ c += " AND year >= #{options[:release_data].split(',')[0]}"
96
+ c += " AND year <= #{options[:release_data].split(',')[1]}"
97
+ end
98
+ if options[:user_rating] && options[:user_rating].include?(',')
99
+ c += " AND rating >= #{options[:user_rating].split(',')[0]}"
100
+ c += " AND rating <= #{options[:user_rating].split(',')[1]}"
101
+ end
102
+ c += " AND genre LIKE '%#{options[:genre]}%'" if options[:genre]
103
+ c = c[5..-1]
104
+ @movies = Movie.find(:all, :conditions => c, :order => 'title ASC')#, :limit => 50)
105
+ return @movies[@start-1..@start+48]
106
+ end
107
+
108
+ def self.next
109
+ @start = @start + 50
110
+ return @movies[@start-1..@start+48] || []
111
+ end
112
+
113
+ def self.get_kind(users, kind)
114
+ c = ''
115
+ users.each { |u| c += "populars.user_id = #{u.id} OR " }
116
+ c = '(' + c[0..-5] + ') AND ' unless c.empty?
117
+ c += "populars.kind = #{Movie.get_code(kind)}"
118
+ Movie.find(:all, :joins => :populars, :conditions => c, :order => 'title ASC', :group => 'movies.id')
119
+ end
120
+
121
+ def self.get_code(what)
122
+ case what.to_sym
123
+ when :to_see: 0
124
+ when :seen: 1
125
+ when :favourites: 2
126
+ else nil
127
+ end
128
+ end
129
+ end
130
+
131
+
132
+ class User < ActiveRecord::Base
133
+ has_many :populars, :dependent => :delete_all
134
+ has_many :movies, :through => :populars
135
+ end
data/src/movie_box.rb ADDED
@@ -0,0 +1,110 @@
1
+ module GtkGimdb
2
+
3
+ class MovieBox < Gtk::HBox
4
+ include GetText
5
+
6
+
7
+ def initialize(movie, users = [])
8
+ bindtextdomain($DOMAIN, $LOCALEDIR, nil, 'UTF-8')
9
+ super()
10
+ @movie = movie
11
+ @users = users
12
+ setting_up
13
+ end
14
+
15
+
16
+ private
17
+
18
+
19
+ def setting_up
20
+ img = Gtk::Image.new((@movie.image_path.nil? || !File.exists?(@movie.image_path)) ? 'data/icons/no_poster.png' : @movie.image_path)
21
+ img.set_tooltip_text("Code: #{@movie.code}")
22
+ self.pack_start(img, false)
23
+
24
+ vbox = Gtk::VBox.new
25
+ vbox.spacing = 10
26
+ hbox1 = Gtk::HBox.new
27
+ hbox1.spacing = 50
28
+ hbox2 = Gtk::HBox.new
29
+ hbox2.spacing = 50
30
+
31
+ title = Gtk::Label.new
32
+ year = (@movie.year.nil? || @movie.year == 0) ? '' : "(#{@movie.year})"
33
+ title.markup = "<a href='http://www.imdb.it/title/#{@movie.code}/'><span color='#000' underline='none' size='large' weight='ultrabold'>#{@movie.title}</span></a> #{year}"
34
+ hbox1.pack_start(title, false)
35
+
36
+ rating = Gtk::Label.new
37
+ rating.markup = "#{@movie.rating}/10" unless @movie.rating.nil? || @movie.rating == 0
38
+ rating.set_tooltip_text(@movie.votes.to_s + ' votes') unless @movie.votes.nil? || @movie.votes == 0
39
+ hbox1.pack_end(rating, false)
40
+
41
+ vbox.pack_start(hbox1, false)
42
+
43
+ outline = Gtk::TextView.new
44
+ outline.buffer.text = @movie.outline || ''
45
+ outline.editable = false
46
+ outline.wrap_mode = Gtk::TextTag::WRAP_WORD_CHAR
47
+ outline.cursor_visible = false
48
+ outline.modify_base(Gtk::STATE_NORMAL, Gdk::Color.parse('#edeceb'))
49
+ vbox.pack_start(outline, false)
50
+
51
+ credit = Gtk::Label.new
52
+ credit.markup = @movie.credit || ''
53
+ credit.set_alignment(0.0, 0.0)
54
+ vbox.pack_start(credit, false)
55
+
56
+ genre = Gtk::Label.new
57
+ genre.text = @movie.genre.nil? ? '' : _('Genres') + ': ' + @movie.genre.gsub('|', ' | ')
58
+ hbox2.pack_start(genre, false)
59
+
60
+ runtime = Gtk::Label.new
61
+ unless @movie.runtime.nil? || @movie.runtime == 0
62
+ runtime.text = @movie.runtime.to_i.to_s + ' mins'
63
+ time = Time.now.midnight + @movie.runtime * 60
64
+ runtime.set_tooltip_text("#{time.hour}:#{"%02d" % time.min}")
65
+ end
66
+ hbox2.pack_end(runtime, false)
67
+
68
+ vbox.pack_start(hbox2, false)
69
+
70
+ vbox.pack_start(add_users_info, false) if @users.size > 0
71
+
72
+ self.pack_start(vbox)
73
+ self.spacing = 10
74
+ end
75
+
76
+
77
+ def add_users_info
78
+ table = Gtk::Table.new(@users.size + 1, 4)
79
+ table.attach(Gtk::Label.new, 0,1, 0,1, Gtk::SHRINK)
80
+ table.attach(Gtk::Label.new.set_markup(_('<b>To see</b>')), 1,2, 0,1, Gtk::SHRINK, Gtk::EXPAND|Gtk::FILL, 10)
81
+ table.attach(Gtk::Label.new.set_markup(_('<b>Seen</b>')), 2,3, 0,1, Gtk::SHRINK, Gtk::EXPAND|Gtk::FILL, 10)
82
+ table.attach(Gtk::Label.new.set_markup(_('<b>Favourites</b>')), 3,4, 0,1, Gtk::SHRINK)
83
+ @users.each_with_index do |u, i|
84
+ row = i + 1
85
+ table.attach(Gtk::Label.new(u.name), 0,1, row,row+1, Gtk::SHRINK)
86
+ table.attach(UserCheckButton.new(u, @movie, :to_see), 1,2, row,row+1, Gtk::SHRINK)
87
+ table.attach(UserCheckButton.new(u, @movie, :seen), 2,3, row,row+1, Gtk::SHRINK)
88
+ table.attach(UserCheckButton.new(u, @movie, :favourites), 3,4, row,row+1, Gtk::SHRINK)
89
+ end
90
+ return table
91
+ end
92
+
93
+
94
+ class UserCheckButton < Gtk::CheckButton
95
+ def initialize(user, movie, what)
96
+ super()
97
+ self.active = movie.get_users(what).include?(user)
98
+ self.signal_connect('toggled') do |b|
99
+ if b.active?
100
+ movie.set_user(user, what)
101
+ else
102
+ movie.remove_user(user, what)
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ end
109
+
110
+ end