saber 1.1.1 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (68) hide show
  1. data/.gitignore +1 -0
  2. data/CHANGELOG.md +8 -1
  3. data/Gemfile +6 -1
  4. data/Gemfile.lock +58 -14
  5. data/README.md +40 -125
  6. data/bin/saber +4 -2
  7. data/bin/saber.bb +3 -0
  8. data/bin/saber.stp +3 -0
  9. data/lib/saber.rb +5 -0
  10. data/lib/saber/autofetcher/server.rb +1 -1
  11. data/lib/saber/book.rb +36 -0
  12. data/lib/saber/cli.rb +45 -19
  13. data/lib/saber/core_ext.rb +68 -0
  14. data/lib/saber/fetcher.rb +2 -2
  15. data/lib/saber/rc.rb +6 -0
  16. data/lib/saber/task.rb +3 -0
  17. data/lib/saber/task/base.rb +6 -1
  18. data/lib/saber/task/chd.rb +10 -2
  19. data/lib/saber/task/clean.rb +2 -2
  20. data/lib/saber/task/find_uploads.rb +53 -0
  21. data/lib/saber/task/generate.rb +37 -20
  22. data/lib/saber/task/make.rb +20 -8
  23. data/lib/saber/task/send.rb +1 -1
  24. data/lib/saber/task/upload.rb +29 -19
  25. data/lib/saber/tracker.rb +8 -4
  26. data/lib/saber/tracker/base.rb +36 -15
  27. data/lib/saber/tracker/bb.rb +4 -10
  28. data/lib/saber/tracker/bib.rb +27 -11
  29. data/lib/saber/tracker/chd.rb +4 -4
  30. data/lib/saber/tracker/gazelle.rb +7 -0
  31. data/lib/saber/tracker/ptp.rb +2 -2
  32. data/lib/saber/tracker/stp.rb +53 -0
  33. data/lib/saber/tracker/what.rb +2 -2
  34. data/lib/saber/tracker2.rb +31 -0
  35. data/lib/saber/tracker2/base.rb +126 -0
  36. data/lib/saber/tracker2/bb.rb +211 -0
  37. data/lib/saber/tracker2/bib.rb +162 -0
  38. data/lib/saber/tracker2/gazelle.rb +52 -0
  39. data/lib/saber/tracker2/stp.rb +136 -0
  40. data/lib/saber/tracker2/what.rb +51 -0
  41. data/lib/saber/version.rb +1 -1
  42. data/lib/saber/watir_ext.rb +95 -0
  43. data/saber.gemspec +6 -1
  44. data/spec/saber/autofetcher/server_spec.rb +1 -1
  45. data/spec/saber/task_spec.rb +1 -1
  46. data/systemd/saber-chd@.service +12 -0
  47. data/systemd/saber-client@.service +12 -0
  48. data/systemd/saber-server@.service +12 -0
  49. data/templates/_saberrc +29 -8
  50. data/templates/article.yml +18 -0
  51. data/templates/bib/application.yml +8 -8
  52. data/templates/bib/audiobook.yml +12 -14
  53. data/templates/comic.yml +25 -0
  54. data/templates/ebook.yml +35 -0
  55. data/templates/journal.yml +19 -0
  56. data/templates/magazine.yml +41 -0
  57. data/templates/manual.yml +6 -0
  58. data/templates/newspaper.yml +6 -0
  59. metadata +109 -13
  60. data/templates/bb/comic.yml +0 -7
  61. data/templates/bb/ebook.yml +0 -8
  62. data/templates/bb/magazine.yml +0 -6
  63. data/templates/bib/article.yml +0 -18
  64. data/templates/bib/comic.yml +0 -22
  65. data/templates/bib/ebook.yml +0 -20
  66. data/templates/bib/journal.yml +0 -18
  67. data/templates/bib/magazine.yml +0 -18
  68. data/templates/what/ebook.yml +0 -6
@@ -0,0 +1,3 @@
1
+ #!/bin/bash
2
+
3
+ SABER_TRACKER=bb saber $*
@@ -0,0 +1,3 @@
1
+ #!/bin/bash
2
+
3
+ SABER_TRACKER=stp saber $*
@@ -3,6 +3,8 @@ require "pa"
3
3
  require "optimism"
4
4
  require "active_support/core_ext/module/attribute_accessors"
5
5
  require "active_support/concern"
6
+ require "active_support/core_ext/numeric/bytes"
7
+ require "saber/core_ext"
6
8
 
7
9
  module Saber
8
10
  autoload :VERSION, "saber/version"
@@ -12,11 +14,14 @@ module Saber
12
14
  autoload :Fetcher, "saber/fetcher"
13
15
  autoload :AutoFetcher, "saber/autofetcher"
14
16
  autoload :Tracker, "saber/tracker"
17
+ autoload :Tracker2, "saber/tracker2"
18
+ autoload :Book, "saber/book"
15
19
 
16
20
  Error = Class.new Exception
17
21
  FatalError = Class.new Exception
18
22
  Rc = Optimism.require "saber/rc", "~/.saberrc"
19
23
 
24
+
20
25
  class << self
21
26
  attr_accessor :ui
22
27
 
@@ -65,7 +65,7 @@ module Saber
65
65
 
66
66
  # ["filea", "foo/filea", "foo/fileb"]
67
67
  def build_files(*names)
68
- Pa.ls2_r(*names, :base_dir => Rc.p.remote_download, :file => true, :include => true) { |p,abs| not Pa.directory?(abs) }
68
+ Pa.ls2_r(*names, :base_dir => Rc.fetch.remote_dir, :file => true, :include => true) { |p,abs| not Pa.directory?(abs) }
69
69
  end
70
70
  end
71
71
  end
@@ -0,0 +1,36 @@
1
+ require "faraday"
2
+ require "faraday_middleware"
3
+
4
+ module Saber
5
+ class Book
6
+ # delegate to #populate
7
+ def self.populate(*args)
8
+ new.populate(*args)
9
+ end
10
+
11
+ attr_reader :client
12
+
13
+ def initialize
14
+ @client = Faraday.new(url: Rc.api_url) {|c|
15
+ c.response :follow_redirects
16
+ c.response :json, :content_type => /\bjson$/
17
+
18
+ c.adapter Faraday.default_adapter
19
+ }
20
+ end
21
+
22
+ # @return [Hash] data
23
+ def populate(isbn, filename)
24
+ params = {}
25
+ rep = client.get("/books/#{isbn}", params)
26
+ data = rep.body
27
+
28
+ if data["status"] == 0
29
+ data["tracker"]
30
+ else
31
+ Saber.ui.error "Can't populate book -- #{isbn} #{filename}."
32
+ {}
33
+ end
34
+ end
35
+ end
36
+ end
@@ -7,28 +7,29 @@ module Saber
7
7
  class_option "no-color", banner: "Disable colorization in output", type: :boolean
8
8
  class_option "verbose", aliases: "-V", banner: "Enable verbose output mode", type: :boolean
9
9
  class_option "log", banner: "Log file", type: :string
10
- class_option "force", aliases: "-f", banner: "Fore writing even if file exists", type: :boolean
10
+ class_option "force", banner: "Fore writing even if file exists", type: :boolean
11
11
  class_option "tracker", aliases: "-t", banner: "tracker name", type: :string
12
-
13
- attr_reader :o
12
+ class_option "dry-run", aliases: "-n", banner: "dry run", type: :boolean
14
13
 
15
14
  def initialize(*)
16
15
  super
17
- o = @o = options.dup
16
+ self.options = self.options.dup
18
17
 
19
- Saber.ui = if o["log"] then
18
+ Saber.ui = if options["log"] then
20
19
  require "logger"
21
- UI::Logger.new(::Logger.new(o["log"]))
20
+ UI::Logger.new(::Logger.new(options["log"]))
22
21
  else
23
22
  the_shell = (options["no-color"] ? Thor::Shell::Basic.new : shell)
24
23
  UI::Shell.new(the_shell)
25
24
  end
26
25
 
27
- Saber.ui.debug! if o["verbose"]
26
+ Saber.ui.debug! if options["verbose"]
28
27
 
29
28
  # Initialize environment in first time
30
29
  unless Rc.p.home.exists?
31
- Pa.mkdir Rc.p.home
30
+ Pa.mkdir Rc.p.home
31
+ Pa.mkdir "#{Rc.p.home}/templates"
32
+ Pa.mkdir "#{Rc.p.home}/database"
32
33
  end
33
34
  end
34
35
 
@@ -36,7 +37,7 @@ module Saber
36
37
  def clean
37
38
  require "saber/task/clean"
38
39
 
39
- Task["clean"].invoke(:clean, [], o)
40
+ Task["clean"].invoke(:clean, [], options)
40
41
  end
41
42
 
42
43
  desc "server", "start saber-server daemon"
@@ -62,18 +63,25 @@ module Saber
62
63
  AutoFetcher::DRbClient.new.add(*names)
63
64
  end
64
65
 
65
- desc "upload [options] <torrent_file/file ...>", "[make a torrent file and] upoad a torrent file to the site"
66
- def upload(*files)
66
+ desc "upload [options] <format> <torrent_file/file ...>", "[make a torrent file and] upoad a torrent file to the site"
67
+ method_option "add", aliases: "-a", desc: "upload via add format.", type: :boolean
68
+ def upload(format0, *files)
67
69
  require "saber/task/upload"
70
+ format = format0.downcase
71
+ Saber.ui.error! "Don't support this format -- #{format0}" unless Rc.book_exts.include?(".#{format}")
68
72
 
69
- Task["upload"].invoke(:upload, [options["tracker"] || ENV["SABER_TRACKER"], *files], o)
73
+ files = files.map{|v| v.dup} # unfrozen string.
74
+ Task["upload"].invoke(:upload, [options["tracker"] || ENV["SABER_TRACKER"], format, *wrap_file(*files)], options)
70
75
  end
71
76
 
72
- desc "generate [options] <type> <filename> [arg ...]", %~generate a meta data file (alias: "g")~
73
- def generate(type, filename, *args)
77
+ desc "generate [options] <type> [filename:isbn ...]", %~generate a meta data file (alias: "g")~
78
+ method_option "file", aliases: "-f", desc: "read files from file list", type: :string
79
+ def generate(type, *files)
74
80
  require "saber/task/generate"
75
81
 
76
- Task["generate"].invoke(:generate, [options["tracker"] || ENV["SABER_TRACKER"], type, filename, *args], o)
82
+ files = File.read(options["file"]).split(/\n+/).map{|v| v.strip} if options["file"]
83
+ files = files.map{|v| name, isbn = v.split(":"); [*wrap_file(name), isbn]}
84
+ Task["generate"].invoke(:generate, [type, *files], options)
77
85
  end
78
86
  map "g" => "generate"
79
87
 
@@ -86,21 +94,39 @@ module Saber
86
94
  def send1(*names)
87
95
  require "saber/task/send"
88
96
 
89
- Task["send"].invoke(:send1, names, o)
97
+ Task["send"].invoke(:send1, names, options)
90
98
  end
91
99
 
92
100
  desc "make [options] <file ..>", "make a torent file and send it to local and/or remote watch directory"
101
+ method_option "option", aliases: "-o", desc: "extra options passed to mktorrent", type: :string
102
+ method_option "file", aliases: "-f", desc: "read files from file list", type: :string
93
103
  def make(*files)
94
104
  require "saber/task/make"
95
105
 
96
- Task["make"].invoke(:make, [options["tracker"] || ENV["SABER_TRACKER"], *files], o)
106
+ files = File.read(options["file"]).split(/\n+/).map{|v| v.strip.split(":")[0] } if options["file"]
107
+ Task["make"].invoke(:make, [options["tracker"] || ENV["SABER_TRACKER"], *files], options)
97
108
  end
98
109
 
99
- desc "chd", "chd"
110
+ desc "chd", "NOT WORKING"
100
111
  def chd
101
112
  require "saber/task/chd"
102
113
 
103
- Task["chd"].invoke(:chd, o)
114
+ Task["chd"].invoke(:chd, [], options)
115
+ end
116
+
117
+ desc "find_uploads", "NOT WORKING"
118
+ def find_uploads(page)
119
+ require "saber/task/find_uploads"
120
+
121
+ Task["find_uploads"].invoke(:find_uploads, [page], options)
122
+ end
123
+
124
+ private
125
+
126
+ # a.yml -> a
127
+ # a.epub.torrent -> a
128
+ def wrap_file(*names)
129
+ names.map{|v| Pa.delete_ext(v, *%w[.torrent .yml]).delete_ext2(*Rc.book_exts)}
104
130
  end
105
131
  end
106
132
  end
@@ -0,0 +1,68 @@
1
+ class Hash
2
+ # From Rails 4.0
3
+
4
+ # Return a new hash with all keys converted by the block operation.
5
+ # This includes the keys from the root hash and from all
6
+ # nested hashes.
7
+ #
8
+ # hash = { person: { name: 'Rob', age: '28' } }
9
+ #
10
+ # hash.deep_transform_keys{ |key| key.to_s.upcase }
11
+ # # => { "PERSON" => { "NAME" => "Rob", "AGE" => "28" } }
12
+ def deep_transform_keys(&block)
13
+ result = {}
14
+ each do |key, value|
15
+ result[yield(key)] = value.is_a?(Hash) ? value.deep_transform_keys(&block) : value
16
+ end
17
+ result
18
+ end
19
+
20
+ # Destructively convert all keys by using the block operation.
21
+ # This includes the keys from the root hash and from all
22
+ # nested hashes.
23
+ def deep_transform_keys!(&block)
24
+ keys.each do |key|
25
+ value = delete(key)
26
+ self[yield(key)] = value.is_a?(Hash) ? value.deep_transform_keys!(&block) : value
27
+ end
28
+ self
29
+ end
30
+
31
+ # Return a new hash with all keys converted to strings.
32
+ # This includes the keys from the root hash and from all
33
+ # nested hashes.
34
+ #
35
+ # hash = { person: { name: 'Rob', age: '28' } }
36
+ #
37
+ # hash.deep_stringify_keys
38
+ # # => { "person" => { "name" => "Rob", "age" => "28" } }
39
+ def deep_stringify_keys
40
+ deep_transform_keys{ |key| key.to_s }
41
+ end
42
+
43
+ # Destructively convert all keys to strings.
44
+ # This includes the keys from the root hash and from all
45
+ # nested hashes.
46
+ def deep_stringify_keys!
47
+ deep_transform_keys!{ |key| key.to_s }
48
+ end
49
+
50
+ # Return a new hash with all keys converted to symbols, as long as
51
+ # they respond to +to_sym+. This includes the keys from the root hash
52
+ # and from all nested hashes.
53
+ #
54
+ # hash = { 'person' => { 'name' => 'Rob', 'age' => '28' } }
55
+ #
56
+ # hash.deep_symbolize_keys
57
+ # # => { person: { name: "Rob", age: "28" } }
58
+ def deep_symbolize_keys
59
+ deep_transform_keys{ |key| key.to_sym rescue key }
60
+ end
61
+
62
+ # Destructively convert all keys to symbols, as long as they respond
63
+ # to +to_sym+. This includes the keys from the root hash and from all
64
+ # nested hashes.
65
+ def deep_symbolize_keys!
66
+ deep_transform_keys!{ |key| key.to_sym rescue key }
67
+ end
68
+ end
@@ -17,7 +17,7 @@ module Saber
17
17
  def add(*files)
18
18
  files.each { |file|
19
19
  uri = "#{Rc.server.ftp}/#{file}"
20
- gid = aria2_add([uri], :dir => Pa.dir2("#{Rc.p.fetcher_download}/#{file}"))
20
+ gid = aria2_add([uri], :dir => Pa.dir2("#{Rc.fetch.dir}/#{file}"))
21
21
  Saber.ui.debug "DOWNLOAD #{gid} #{uri}"
22
22
  }
23
23
  end
@@ -28,7 +28,7 @@ module Saber
28
28
  files = []
29
29
  Net::SSH.start(Rc.server.host, Rc.server.user) do |s|
30
30
  name = "'#{names.join("' '")}'"
31
- cmd = "cd #{Rc.p.remote_download} && find #{name} -type f"
31
+ cmd = "cd #{Rc.fetch.remote_dir} && find #{name} -type f"
32
32
 
33
33
  rst = s.exec!(cmd)
34
34
  if rst =~ /^find: `|^cd:cd:/
@@ -1,9 +1,15 @@
1
+ api_url = "http://saberapi.heroku.com"
1
2
  scgi_server = "http://localhost/RPC2"
3
+ browser = [:firefox]
4
+ book_exts = %w[.epub .mobi .pdf .txt .html .djvu .chm .cbr .cbz .azw3]
5
+ book_formats = %w[epub mobi pdf txt html djvu chm cbr cbz azw3]
2
6
 
3
7
  p:
4
8
  root = Pa.expand("../../..", __FILE__)
5
9
  home = Pa("~/.saber")
6
10
  homerc = Pa("~/.saberrc")
11
+ database = Pa("~/.saber/database")
12
+ template = Pa("~/.saber/templates")
7
13
 
8
14
  aria2:
9
15
  rpc = "http://localhost:6800/rpc"
@@ -18,6 +18,7 @@ module Saber
18
18
  autoload :Clean, "saber/task/clean"
19
19
  autoload :Make, "saber/task/make"
20
20
  autoload :Upload, "saber/task/upload"
21
+ autoload :FindUploads, "saber/task/find_uploads"
21
22
 
22
23
  @@tasks = {}
23
24
 
@@ -26,6 +27,8 @@ module Saber
26
27
 
27
28
  class << self
28
29
  def [](name)
30
+ require "saber/task/#{name}"
31
+
29
32
  tasks[name]
30
33
  end
31
34
  end
@@ -8,11 +8,16 @@ module Saber
8
8
  Task.tasks[child.name.demodulize.underscore] = child
9
9
  end
10
10
 
11
- # invoke a task
11
+ # delegate to #invoke
12
12
  def invoke(*args)
13
13
  new.invoke(*args)
14
14
  end
15
15
  end
16
+
17
+ def initialize(*)
18
+ super
19
+ self.options = self.options.dup
20
+ end
16
21
  end
17
22
  end
18
23
  end
@@ -1,10 +1,11 @@
1
+ require "sys/filesystem"
2
+
1
3
  module Saber
2
4
  module Task
3
5
  class CHD < Base
4
6
 
5
7
  desc "chd", "chd"
6
8
  def chd(o={})
7
- require "saber/tracker/chd"
8
9
  t = Tracker["chd"].new
9
10
 
10
11
  begin
@@ -14,7 +15,14 @@ module Saber
14
15
  t.update_cache(true)
15
16
 
16
17
  while true
17
- t.add_torrents
18
+ # check free diskspace
19
+ s = Sys::Filesystem.stat(Rc.chd.diskspace_dir.p)
20
+ if s.block_size * s.blocks_free < Rc.chd.diskspace_limit
21
+ Saber.ui.say "::SKIP:: Reach low diskspace."
22
+ else
23
+ t.add_torrents
24
+ end
25
+
18
26
  sleep Rc.chd.update_interval
19
27
  end
20
28
  rescue Errno::ETIMEDOUT, Mechanize::ResponseCodeError, SocketError
@@ -6,8 +6,8 @@ module Saber
6
6
  class Clean < Base
7
7
  desc "clean", "clean"
8
8
  def clean
9
- disk_files = Pa.ls2(Rc.p.download, absolute: true)
10
- bt_files = Retort::Torrent.all.map{|t| Retort::Torrent.action("name", t.info_hash) }.map{|n| Pa.join2(Rc.p.download, n)}
9
+ disk_files = Pa.ls2(Rc.clean.dir, absolute: true)
10
+ bt_files = Retort::Torrent.all.map{|t| Retort::Torrent.action("name", t.info_hash) }.map{|n| Pa.join2(Rc.clean.dir, n)}
11
11
 
12
12
  (disk_files - bt_files).each { |file|
13
13
  Pa.rm_r file, :verbose => true
@@ -0,0 +1,53 @@
1
+ require "tagen/core/io"
2
+ require "isbn"
3
+
4
+ module Saber
5
+ module Task
6
+ class FindUploads < Base
7
+ desc "find_uploads", "find_uploads"
8
+ def find_uploads(page="1")
9
+ bib = Tracker["bib"].new(options)
10
+ stp = Tracker["stp"].new(options)
11
+ dir = Rc._fetch("find_uploads.dir", ".")
12
+ bib.agent.pluggable_parser["application/x-bittorrent"] = Mechanize::DirectorySaver.save_to(dir.to_s)
13
+ bib.login
14
+ stp.login
15
+
16
+ bib.browse(page) {|torrent|
17
+ title, isbn, download_link, filenames, tags = torrent[:title], torrent[:isbn],
18
+ torrent[:download_link], torrent[:filenames], torrent[:tags]
19
+
20
+ generic_tags = convert_bibtags(tags)
21
+
22
+ begin
23
+ isbn = ISBN.thirteen(torrent[:isbn])
24
+ rescue ISBN::Invalid13DigitISBN # empty
25
+ next
26
+ end
27
+
28
+ if not stp.exists?(isbn: isbn)
29
+ bib.get(download_link)
30
+
31
+ Saber.ui.say "#{isbn} #{title}\n #{filenames.join("\n ")}"
32
+ File.append("list", "#{filenames[0]}:#{isbn}\n")
33
+
34
+ # local data
35
+ local_data = Pa.exists?("#{Rc.p.database}/#{isbn}.yml") ? YAML.load_file("#{Rc.p.database}/#{isbn}.yml") : {}
36
+ local_data.merge!({"tags" => generic_tags.join(", "), "bib.tags" => tags.join(", ")})
37
+ File.write "#{Rc.p.database}/#{isbn}.yml", YAML.dump(local_data)
38
+ end
39
+ }
40
+ end
41
+
42
+ private
43
+
44
+ def convert_bibtags(tags)
45
+ tags.map{|v|
46
+ v.gsub(/ \(programming\)/i, '')
47
+ }.sort_by {|v|
48
+ %w[fiction nonfiction].include?(v) ? -1 : tags.index(v)
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
@@ -1,3 +1,5 @@
1
+ require "isbn"
2
+
1
3
  module Saber
2
4
  module Task
3
5
  # Generate meta data file.
@@ -5,34 +7,49 @@ module Saber
5
7
  # Usage
6
8
  # -----
7
9
  #
8
- # Task["generate"].invoke(:generate, ["bib", "ebook", "Hello.epub", isbn])
10
+ # Task["generate"].invoke(:generate, ["ebook", ["harry_potter", isbn], ...])
9
11
  # > generate Hello.epub.yml data file.
10
12
  #
11
13
  class Generate < Base
12
14
  include Thor::Actions
13
15
 
16
+ def self.source_paths
17
+ ["#{Rc.p.home}/templates"]
18
+ end
19
+
14
20
  source_root "#{Rc.p.root}/templates"
15
21
 
16
22
  desc "generate", "generate"
17
- def generate(tracker_name, type, filename, *args)
18
- require "saber/tracker/#{tracker_name}" if !args.empty?
19
- template_file = find_in_source_paths("#{tracker_name}/#{type}.yml")
20
- dest = "#{filename}.yml"
21
-
22
- if !args.empty? and Tracker[tracker_name].can_populate?(type)
23
- require "yaml"
24
- populate = {}
25
- data = YAML.load_file(template_file)
26
-
27
- tracker = Tracker[tracker_name].new
28
- tracker.login
29
- populate = tracker.populate(type, *args)
30
- data.merge!(populate)
31
-
32
- create_file dest, YAML.dump(data)
33
- else
34
- copy_file template_file, dest
35
- end
23
+ def generate(type, *filenames)
24
+ template_file = find_in_source_paths("#{type}.yml")
25
+
26
+ filenames.each {|filename, isbn|
27
+ isbn = ISBN.thirteen(isbn) rescue nil
28
+ Saber.ui.say "Populating {#{isbn}} #{filename} ..."
29
+ dest = "#{filename}.yml"
30
+
31
+ if isbn
32
+ require "yaml"
33
+ populate = {}
34
+ data = YAML.load_file(template_file)
35
+ data.merge! Book.populate(isbn, filename)
36
+ data.merge! YAML.load_file("#{Rc.p.database}/#{isbn}.yml") if Pa.exists?("#{Rc.p.database}/#{isbn}.yml")
37
+
38
+ # tags
39
+ if data["tags"]
40
+ Tracker.trackers.each {|name, tracker_class|
41
+ if data["#{name}.tags"].nil? and tracker_class.method_defined?(:convert_tags)
42
+ tracker = tracker_class.new(options)
43
+ data["#{name}.tags"] = tracker.convert_tags(*data["tags"].split(/, */)).join(", ")
44
+ end
45
+ }
46
+ end
47
+
48
+ create_file dest, YAML.dump(data)
49
+ else
50
+ copy_file template_file, dest
51
+ end
52
+ }
36
53
  end
37
54
  end
38
55
  end