releasy 0.2.0 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. data/.yardopts +3 -1
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +114 -41
  4. data/Rakefile +1 -1
  5. data/bin/7z_sfx_LICENSE.txt +56 -0
  6. data/bin/7za.exe +0 -0
  7. data/bin/7za_exe_LICENSE.txt +29 -0
  8. data/lib/releasy.rb +4 -1
  9. data/lib/releasy/builders/builder.rb +14 -6
  10. data/lib/releasy/builders/ocra_builder.rb +4 -2
  11. data/lib/releasy/builders/osx_app.rb +18 -6
  12. data/lib/releasy/builders/source.rb +9 -2
  13. data/lib/releasy/builders/windows_folder.rb +12 -3
  14. data/lib/releasy/builders/windows_installer.rb +2 -3
  15. data/lib/releasy/builders/windows_standalone.rb +13 -3
  16. data/lib/releasy/builders/windows_wrapped.rb +14 -4
  17. data/lib/releasy/cli/install_sfx.rb +1 -1
  18. data/lib/releasy/deployers.rb +1 -1
  19. data/lib/releasy/deployers/deployer.rb +22 -1
  20. data/lib/releasy/deployers/github.rb +31 -124
  21. data/lib/releasy/deployers/local.rb +55 -0
  22. data/lib/releasy/deployers/rsync.rb +59 -0
  23. data/lib/releasy/mixins/has_packagers.rb +1 -1
  24. data/lib/releasy/mixins/utilities.rb +34 -0
  25. data/lib/releasy/packagers/dmg.rb +17 -0
  26. data/lib/releasy/packagers/exe.rb +18 -1
  27. data/lib/releasy/packagers/packager.rb +22 -6
  28. data/lib/releasy/packagers/seven_zip.rb +17 -0
  29. data/lib/releasy/packagers/tar_bzip2.rb +19 -0
  30. data/lib/releasy/packagers/tar_gzip.rb +19 -0
  31. data/lib/releasy/packagers/tar_packager.rb +2 -1
  32. data/lib/releasy/packagers/zip.rb +17 -0
  33. data/lib/releasy/project.rb +45 -4
  34. data/lib/releasy/version.rb +1 -1
  35. data/releasy.gemspec +1 -1
  36. data/test/releasy/builders/data/Main.rb +3 -2
  37. data/test/releasy/builders/helpers/builder_helper.rb +35 -0
  38. data/test/releasy/builders/helpers/ocra_builder_helper.rb +31 -0
  39. data/test/releasy/builders/helpers/windows_builder_helper.rb +25 -0
  40. data/test/releasy/builders/osx_app_test.rb +6 -4
  41. data/test/releasy/builders/source_test.rb +7 -4
  42. data/test/releasy/builders/windows_folder_test.rb +3 -1
  43. data/test/releasy/builders/windows_installer_test.rb +6 -4
  44. data/test/releasy/builders/windows_standalone_test.rb +6 -4
  45. data/test/releasy/builders/windows_wrapped_test.rb +3 -1
  46. data/test/releasy/cli/install_sfx_test.rb +1 -1
  47. data/test/releasy/deployers/github_test.rb +30 -32
  48. data/test/releasy/deployers/local_test.rb +93 -0
  49. data/test/releasy/deployers/rsync_test.rb +55 -0
  50. data/test/releasy/integration/source_test.rb +6 -6
  51. data/test/releasy/mixins/utilities_test.rb +50 -0
  52. data/test/releasy/packagers/packager_test.rb +79 -0
  53. data/test/releasy/packagers_test.rb +12 -6
  54. data/test/releasy/project_test.rb +5 -5
  55. data/test/teststrap.rb +6 -0
  56. data/test/yard_test.rb +1 -1
  57. metadata +40 -28
  58. data/lib/releasy/mixins/exec.rb +0 -14
  59. data/test/releasy/builders/ocra_builder_test.rb +0 -37
  60. data/test/releasy/builders/windows_builder_test.rb +0 -26
@@ -6,6 +6,18 @@ module Releasy
6
6
  module Builders
7
7
  # Builds an OS X application bundle.
8
8
  #
9
+ # @example
10
+ # Releasy::Project.new do
11
+ # name "My App"
12
+ # add_build :osx_app do
13
+ # wrapper "gosu-mac-wrapper-0.7.41.tar.gz" # Required, see {#wrapper=}.
14
+ # url "com.cheese.myapp" # Required
15
+ # icon "media/icon.icns" # Optional
16
+ # exclude_encoding # Optional
17
+ # add_package :dmg # Optional
18
+ # end
19
+ # end
20
+ #
9
21
  # @attr icon [String] Optional filename of icon to show on executable/installer (.icns).
10
22
  class OsxApp < Builder
11
23
  include Mixins::HasGemspecs
@@ -49,13 +61,12 @@ module Releasy
49
61
  raise ConfigError, "#wrapper not set" unless wrapper
50
62
  raise ConfigError, "#wrapper not valid wrapper: #{wrapper}" unless File.basename(wrapper) =~ VALID_GOSU_WRAPPER
51
63
 
52
- directory folder
53
-
54
64
  desc "Build OS X app"
55
65
  task "build:osx:app" => folder
56
66
 
57
67
  file folder => project.files + [wrapper] do
58
- build
68
+ mkdir_p folder, fileutils_options
69
+ build
59
70
  end
60
71
  end
61
72
 
@@ -66,7 +77,7 @@ module Releasy
66
77
  new_app = File.join folder, app_name
67
78
 
68
79
  # Copy the app files.
69
- exec %[7z x -so -bd "#{wrapper}" | 7z x -si -mmt -bd -ttar -o"#{folder}"]
80
+ execute_command %[7z x -so -bd "#{wrapper}" 2>#{null_file} | 7z x -si -mmt -bd -ttar -o"#{folder}"]
70
81
  mv File.join(folder, "RubyGosu App.app"), new_app, fileutils_options
71
82
 
72
83
  ## Copy my source files.
@@ -156,10 +167,11 @@ OSX_EXECUTABLE_FOLDER = File.expand_path("../../..", __FILE__)
156
167
 
157
168
  # Really hacky fudge-fix for something oddly missing in the .app.
158
169
  class Encoding
159
- UTF_7 = UTF_16BE = UTF_16LE = UTF_32BE = UTF_32LE = Encoding.list.first
170
+ BINARY = UTF_7 = UTF_16BE = UTF_16LE = UTF_32BE = UTF_32LE = Encoding.list.first
160
171
  end
161
172
 
162
- load 'application/#{project.executable}'
173
+ Dir.chdir 'application'
174
+ load '#{project.executable}'
163
175
  END_TEXT
164
176
  end
165
177
  end
@@ -3,6 +3,14 @@ require "releasy/builders/builder"
3
3
  module Releasy
4
4
  module Builders
5
5
  # Creates a folder containing the application source.
6
+ #
7
+ # @example
8
+ # Releasy::Project.new do
9
+ # name "My App"
10
+ # add_build :source do
11
+ # add_package :zip # Optional.
12
+ # end
13
+ # end
6
14
  class Source < Builder
7
15
  TYPE = :source
8
16
  Builders.register self
@@ -14,9 +22,8 @@ module Releasy
14
22
  desc "Build source"
15
23
  task "build:source" => folder
16
24
 
17
- directory folder
18
-
19
25
  file folder => project.files do
26
+ mkdir_p folder, fileutils_options
20
27
  copy_files_relative project.files, folder
21
28
  end
22
29
  end
@@ -4,6 +4,16 @@ require 'releasy/windows_wrapper_maker'
4
4
  module Releasy
5
5
  module Builders
6
6
  # Builds a folder containing Ruby + your source + a small Windows executable to run your executable script.
7
+ #
8
+ # @example
9
+ # Releasy::Project.new do
10
+ # name "My App"
11
+ # add_build :windows_folder do
12
+ # icon "media/icon.ico" # Optional
13
+ # exclude_encoding # Optional
14
+ # add_package :zip # Optional
15
+ # end
16
+ # end
7
17
  class WindowsFolder < OcraBuilder
8
18
  TYPE = :windows_folder
9
19
  DEFAULT_FOLDER_SUFFIX = "WIN32"
@@ -13,12 +23,11 @@ module Releasy
13
23
  protected
14
24
  # FOLDER containing EXE, Ruby + source.
15
25
  def generate_tasks
16
- directory project.output_path
17
-
18
26
  file folder => project.files do
27
+ mkdir_p project.output_path, fileutils_options
19
28
  tmp_ocra_executable = "#{folder}.exe"
20
29
 
21
- exec %[#{ocra_command} --output "#{tmp_ocra_executable}" --debug-extract]
30
+ execute_command %[#{ocra_command} --output "#{tmp_ocra_executable}" --debug-extract]
22
31
 
23
32
  # Extract the files from the executable.
24
33
  system tmp_ocra_executable
@@ -21,9 +21,8 @@ module Releasy
21
21
  protected
22
22
  # Regular windows installer, but some users consider them evil.
23
23
  def generate_tasks
24
- directory folder
25
-
26
24
  file folder => project.files do
25
+ mkdir_p folder, fileutils_options
27
26
  create_link_files folder
28
27
  project.exposed_files.each {|file| cp file, folder, fileutils_options }
29
28
 
@@ -51,7 +50,7 @@ module Releasy
51
50
  protected
52
51
  def create_installer(file, options = {})
53
52
  generate_installer_script file, options
54
- exec %[#{ocra_command} --chdir-first --no-lzma --innosetup "#{temp_installer_script}"]
53
+ execute_command %[#{ocra_command} --chdir-first --no-lzma --innosetup "#{temp_installer_script}"]
55
54
  end
56
55
 
57
56
  # Generate innosetup script to build installer.
@@ -5,6 +5,16 @@ module Releasy
5
5
  # Creates a completely standalone Windows executable.
6
6
  #
7
7
  # @note Startup of the executable created by this build takes a couple of seconds longer than running from the other windows builds, as the files are extracted into a temporary directory each time it is run. It is recommended to build with _:windows_folder_ or _:windows_installer_ instead of this, unless you really need to distribute the application as a single file.
8
+ #
9
+ # @example
10
+ # Releasy::Project.new do
11
+ # name "My App"
12
+ # add_build :windows_standalone do
13
+ # icon "media/icon.ico" # Optional
14
+ # exclude_encoding # Optional
15
+ # add_package :zip # Optional
16
+ # end
17
+ # end
8
18
  class WindowsStandalone < OcraBuilder
9
19
  TYPE = :windows_standalone
10
20
  DEFAULT_FOLDER_SUFFIX = "WIN32_EXE"
@@ -14,14 +24,14 @@ module Releasy
14
24
  protected
15
25
  # Self-extracting standalone executable.
16
26
  def generate_tasks
17
- directory folder
18
-
19
27
  file folder => project.files do
28
+ mkdir_p folder, fileutils_options
29
+
20
30
  project.exposed_files.each {|file| cp file, folder, fileutils_options }
21
31
 
22
32
  create_link_files folder
23
33
 
24
- exec %[#{ocra_command} --output "#{folder}/#{executable_name}"]
34
+ execute_command %[#{ocra_command} --output "#{folder}/#{executable_name}"]
25
35
  end
26
36
 
27
37
  desc "Build standalone exe #{project.version} [Ocra]"
@@ -11,6 +11,17 @@ module Releasy
11
11
  # Limitations:
12
12
  # * Does not DLLs loaded from the system, which will have to be included manually if any are required by the application and no universally available in Windows installations.
13
13
  # * Unless a gem is in pure Ruby or available as a pre-compiled binary gem, it won't work!
14
+ #
15
+ # @example
16
+ # Releasy::Project.new do
17
+ # name "My App"
18
+ # add_build :windows_wrapped do
19
+ # wrapper "ruby-1.9.3-p0-i386-mingw32.7z" # Required
20
+ # exclude_encoding # Optional
21
+ # exclude_tcl_tk # Optional
22
+ # add_package :zip # Optional
23
+ # end
24
+ # end
14
25
  class WindowsWrapped < WindowsBuilder
15
26
  include Mixins::HasGemspecs
16
27
 
@@ -48,9 +59,8 @@ module Releasy
48
59
  raise ConfigError, "#wrapper not set" unless wrapper
49
60
  raise ConfigError, "#wrapper not valid wrapper: #{wrapper}" unless File.basename(wrapper) =~ VALID_RUBY_DIST
50
61
 
51
- directory project.output_path
52
-
53
62
  file folder => project.files + [wrapper] do
63
+ mkdir_p project.output_path, fileutils_options
54
64
  build
55
65
  end
56
66
 
@@ -104,7 +114,7 @@ module Releasy
104
114
  protected
105
115
  def copy_ruby_distribution
106
116
  archive_name = File.basename(wrapper).chomp(File.extname(wrapper))
107
- exec %[7z x "#{wrapper}" -o"#{File.dirname folder}"]
117
+ execute_command %[7z x "#{wrapper}" -o"#{File.dirname folder}"]
108
118
  mv File.join(File.dirname(folder), archive_name), folder, fileutils_options
109
119
  rm_r File.join(folder, "share"), fileutils_options
110
120
  rm_r File.join(folder, "include"), fileutils_options if File.exists? File.join(folder, "include")
@@ -138,7 +148,7 @@ module Releasy
138
148
  # If we have a bundle file specified, then gem will _only_ install the version specified by it and not the one we request.
139
149
  bundle_gemfile = ENV['BUNDLE_GEMFILE']
140
150
  ENV['BUNDLE_GEMFILE'] = ''
141
- exec %[gem install "#{spec.name}" --remote --no-rdoc --no-ri --force --ignore-dependencies --platform "#{windows_platform}" --version "#{spec.version}" --install-dir "#{destination}"]
151
+ execute_command %[gem install "#{spec.name}" --remote --no-rdoc --no-ri --force --ignore-dependencies --platform "#{windows_platform}" --version "#{spec.version}" --install-dir "#{destination}"]
142
152
  ENV['BUNDLE_GEMFILE'] = bundle_gemfile
143
153
  binary_gems << spec.name
144
154
  end
@@ -53,7 +53,7 @@ Releasy::Cli.define_command do
53
53
  if ENV["USER"] != "root"
54
54
  command = %[sudo cp "#{sfx_path}" "#{assets_location}"]
55
55
  puts "Copy failed, trying again as super-user:\n#{command}"
56
- exec command
56
+ system command
57
57
  if File.exists? destination_file
58
58
  puts "#{sfx_file} copied to #{assets_location}"
59
59
  else
@@ -7,6 +7,6 @@ module Releasy
7
7
  end
8
8
  end
9
9
 
10
- %w[github].each do |deployer|
10
+ %w[github local rsync].each do |deployer|
11
11
  require "releasy/deployers/#{deployer}"
12
12
  end
@@ -1,5 +1,6 @@
1
1
  require "releasy/mixins/log"
2
2
 
3
+
3
4
  module Releasy
4
5
  module Deployers
5
6
  # @abstract
@@ -7,6 +8,9 @@ module Releasy
7
8
  include Rake::DSL
8
9
  include Mixins::Log
9
10
 
11
+ # Printed out while file is being transferred.
12
+ WORKING_CHARACTER = "."
13
+
10
14
  attr_reader :project
11
15
 
12
16
  def type; self.class::TYPE; end
@@ -20,9 +24,26 @@ module Releasy
20
24
  def generate_tasks(archive_task, folder, extension)
21
25
  desc "#{type} <= #{archive_task.split(":")[0..-2].join(" ")} #{extension}"
22
26
  task "deploy:#{archive_task}:#{type}" => "package:#{archive_task}" do
23
- deploy(folder + extension)
27
+ do_deploy(folder + extension)
24
28
  end
25
29
  end
30
+
31
+ protected
32
+ def do_deploy(file)
33
+ heading "Deploying #{file} (#{(File.size(file).fdiv 1024).ceil}k) to #{self.class.name[/[^:]+$/]}"
34
+
35
+ t = Time.now
36
+
37
+ deploy file
38
+
39
+ minutes, seconds = (Time.now - t).ceil.divmod 60
40
+ hours, minutes = minutes.divmod 60
41
+ duration = "%d:%02d:%02d" % [hours, minutes, seconds]
42
+
43
+ heading "Successfully deployed file in #{duration}"
44
+
45
+ nil
46
+ end
26
47
  end
27
48
  end
28
49
  end
@@ -4,131 +4,45 @@ module Releasy
4
4
  module Deployers
5
5
  # Deploys to a Github project's downloads page.
6
6
  #
7
- # @attr description [String] Description of file (defaults to: "#{project.description")
8
- # @attr login [String] Github user name that has write access to {#repository} (defaults to: `git config github.user` or user name in `git config remote.origin.url`).
9
- # @attr repository [String] Name of Github repository (defaults to: the repository name in `git config remote.origin.url` or _project.underscored_name_).
10
- # @attr token [String] Github token associated with {#login} - a 32-digit hexadecimal string - DO NOT COMMIT A FILE CONTAINING YOUR GITHUB TOKEN (defaults to: `git config github.token`)
7
+ # @example
8
+ # Releasy::Project.new do
9
+ # name "My App"
10
+ # add_build :source
11
+ # add_package :zip
12
+ # add_deploy :github # Should be enough if git is configured correctly.
13
+ # end
14
+ #
15
+ # @attr description [String] (project.description) Description of file.
16
+ # @attr user [String] (`git config github.user` or user name in `git config remote.origin.url`) Github user name that has write access to {#repository}
17
+ # @attr repository [String] (repository name in `git config remote.origin.url` or _project.underscored_name_) Name of Github repository.
18
+ # @attr token [String] (`git config github.token`) Github token associated with {#user} - a 32-digit hexadecimal string - DO NOT COMMIT A FILE CONTAINING YOUR GITHUB TOKEN!
11
19
  class Github < Deployer
12
- # Patch to add an asynchronous version of upload, that also yields every second and takes into account timeout.
13
- module UploaderUploadAsync
14
- def upload_async(info)
15
- unless info[:repos]
16
- raise "required repository name"
17
- end
18
- info[:repos] = @login + '/' + info[:repos] unless info[:repos].include? '/'
19
-
20
- if info[:file]
21
- file = info[:file]
22
- unless File.exist?(file) && File.readable?(file)
23
- raise "file does not exsits or readable"
24
- end
25
- info[:name] ||= File.basename(file)
26
- end
27
- unless info[:file] || info[:data]
28
- raise "required file or data parameter to upload"
29
- end
30
-
31
- unless info[:name]
32
- raise "required name parameter for filename with data parameter"
33
- end
34
-
35
- if info[:replace]
36
- list_files(info[:repos]).each { |obj|
37
- next unless obj[:name] == info[:name]
38
- delete info[:repos], obj[:id]
39
- }
40
- elsif list_files(info[:repos]).any?{|obj| obj[:name] == info[:name]}
41
- raise "file '#{info[:name]}' is already uploaded. please try different name"
42
- end
43
-
44
- info[:content_type] ||= 'application/octet-stream'
45
- stat = HTTPClient.post("https://github.com/#{info[:repos]}/downloads", {
46
- "file_size" => info[:file] ? File.stat(info[:file]).size : info[:data].size,
47
- "content_type" => info[:content_type],
48
- "file_name" => info[:name],
49
- "description" => info[:description] || '',
50
- "login" => @login,
51
- "token" => @token
52
- })
53
-
54
- unless stat.code == 200
55
- raise "Failed to post file info"
56
- end
57
-
58
- upload_info = JSON.parse(stat.content)
59
- if info[:file]
60
- f = File.open(info[:file], 'rb')
61
- else
62
- f = Tempfile.open('net-github-upload')
63
- f << info[:data]
64
- f.flush
65
- end
66
-
67
- client = HTTPClient.new
68
- client.send_timeout = info[:upload_timeout] if info[:upload_timeout]
69
-
70
- res = begin
71
- connection = client.post_async("http://github.s3.amazonaws.com/", [
72
- ['Filename', info[:name]],
73
- ['policy', upload_info['policy']],
74
- ['success_action_status', 201],
75
- ['key', upload_info['path']],
76
- ['AWSAccessKeyId', upload_info['accesskeyid']],
77
- ['Content-Type', upload_info['content_type'] || 'application/octet-stream'],
78
- ['signature', upload_info['signature']],
79
- ['acl', upload_info['acl']],
80
- ['file', f]
81
- ])
82
-
83
- until connection.finished?
84
- yield if block_given?
85
- sleep info[:yield_interval] || 1
86
- end
87
-
88
- connection.pop
89
- ensure
90
- f.close
91
- end
92
-
93
- if res.status == 201
94
- return FasterXmlSimple.xml_in(res.body.read)['PostResponse']['Location']
95
- else
96
- raise 'Failed to upload' + extract_error_message(res.body)
97
- end
98
- end
99
- end
100
-
101
-
102
20
  TYPE = :github
103
21
  # Maximum time to allow an upload to continue. An hour to upload a file isn't unreasonable. Better than the default 2 minutes, which uploads about 4MB for me.
104
22
  UPLOAD_TIMEOUT = 60 * 60
105
23
 
106
24
  Deployers.register self
107
25
 
108
- def repository
109
- @repository || project.underscored_name
110
- end
26
+ def repository; @repository || project.underscored_name; end
111
27
  def repository=(repository)
112
28
  raise TypeError, "repository must be a String, but received #{repository.class}" unless repository.is_a? String
113
29
  @repository = repository
114
30
  end
115
31
 
116
- attr_reader :user
32
+ def user; @user; end
117
33
  def user=(user)
118
34
  raise TypeError, "user must be a String, but received #{user.class}" unless user.is_a? String
119
35
  @user = user
120
36
  end
121
37
 
122
- attr_reader :token
38
+ def token; @token; end
123
39
  def token=(token)
124
40
  raise TypeError, "token must be a String, but received #{token.class}" unless token.is_a? String
125
41
  raise ArgumentError, "token invalid (expected 32-character hex string)" unless token =~ /^[0-9a-f]{32}$/i
126
42
  @token = token
127
43
  end
128
44
 
129
- def description
130
- @description || project.description
131
- end
45
+ def description; @description || project.description; end
132
46
  def description=(description)
133
47
  raise TypeError, "description must be a String, but received #{description.class}" unless description.is_a? String
134
48
  @description = description
@@ -164,44 +78,37 @@ module Releasy
164
78
  # @param key [String] Name of setting in git config.
165
79
  # @return [String, nil] Value of setting, else nil if it isn't defined.
166
80
  def from_config(key)
167
- `git config #{key}`.chomp rescue nil
81
+ Kernel.`("git config #{key}").chomp rescue nil
168
82
  end
169
83
 
170
84
  protected
171
85
  # @param file [String] Path to file to deploy.
172
- # @return [String] A link to download the file.
173
- # @raise SystemError If file fails to upload.
86
+ # @return [nil]
174
87
  def deploy(file)
175
88
  raise ConfigError, "#user must be set manually if it is not configured on the system" unless user
176
89
  raise ConfigError, "#token must be set manually if it is not configured on the system" unless token
177
90
 
91
+ info %[Uploading to: https://github.com/downloads/#{user}/#{repository}/#{File.basename(file)}]
92
+
178
93
  # Hold off requiring this unless needed, so it doesn't slow down creating tasks.
179
- if require 'net/github-upload'
180
- Net::GitHub::Upload.send :include, UploaderUploadAsync
181
- end
94
+ require 'net/github-upload'
182
95
 
183
96
  uploader = Net::GitHub::Upload.new(:login => user, :token => token)
184
97
 
185
- heading "Deploying #{file} (#{(File.size(file).fdiv 1024).ceil}k) to Github"
186
-
187
- t = Time.now
188
-
189
98
  begin
190
- uploader.upload_async :repos => repository, :file => file, :description => description, :replace => @force_replace, :upload_timeout => UPLOAD_TIMEOUT do
191
- print '.'
99
+ uploader.upload :repos => repository, :file => file, :description => description, :replace => @force_replace, :upload_timeout => UPLOAD_TIMEOUT do
100
+ print WORKING_CHARACTER unless log_level == :silent
101
+ end
102
+ info WORKING_CHARACTER
103
+ rescue RuntimeError => ex
104
+ if ex.message =~ /file .* is already uploaded/i
105
+ warn "Skipping '#{File.basename file}' as it is already uploaded. Use #replace! to force uploading"
106
+ else
107
+ raise ex
192
108
  end
193
- puts '.'
194
- rescue => ex
195
- # Probably failed to overwrite an existing file.
196
- error "Error uploading file #{file}: #{ex.message}"
197
- exit 1 # This is bad. Lets just die, die, die at this point.
198
109
  end
199
110
 
200
- link = "https://github.com/downloads/#{user}/#{repository}/#{File.basename(file)}"
201
- time = "%d:%02d" % (Time.now - t).ceil.divmod(60)
202
- heading %[Successfully uploaded to "#{link}" in #{time}]
203
-
204
- link
111
+ nil
205
112
  end
206
113
  end
207
114
  end