spaceship 0.0.15 → 0.1.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/lib/assets/languageMapping.json +224 -0
  3. data/lib/spaceship.rb +20 -63
  4. data/lib/spaceship/base.rb +71 -14
  5. data/lib/spaceship/client.rb +9 -274
  6. data/lib/spaceship/launcher.rb +1 -1
  7. data/lib/spaceship/portal/app.rb +125 -0
  8. data/lib/spaceship/portal/certificate.rb +273 -0
  9. data/lib/spaceship/portal/device.rb +102 -0
  10. data/lib/spaceship/portal/portal.rb +6 -0
  11. data/lib/spaceship/portal/portal_base.rb +13 -0
  12. data/lib/spaceship/portal/portal_client.rb +289 -0
  13. data/lib/spaceship/portal/provisioning_profile.rb +369 -0
  14. data/lib/spaceship/portal/spaceship.rb +94 -0
  15. data/lib/spaceship/{ui → portal/ui}/select_team.rb +0 -0
  16. data/lib/spaceship/tunes/app_screenshot.rb +28 -0
  17. data/lib/spaceship/tunes/app_status.rb +63 -0
  18. data/lib/spaceship/tunes/app_submission.rb +149 -0
  19. data/lib/spaceship/tunes/app_version.rb +337 -0
  20. data/lib/spaceship/tunes/application.rb +253 -0
  21. data/lib/spaceship/tunes/build.rb +128 -0
  22. data/lib/spaceship/tunes/build_train.rb +79 -0
  23. data/lib/spaceship/tunes/language_converter.rb +44 -0
  24. data/lib/spaceship/tunes/language_item.rb +54 -0
  25. data/lib/spaceship/tunes/processing_build.rb +30 -0
  26. data/lib/spaceship/tunes/spaceship.rb +26 -0
  27. data/lib/spaceship/tunes/tester.rb +177 -0
  28. data/lib/spaceship/tunes/tunes.rb +12 -0
  29. data/lib/spaceship/tunes/tunes_base.rb +15 -0
  30. data/lib/spaceship/tunes/tunes_client.rb +360 -0
  31. data/lib/spaceship/version.rb +1 -1
  32. metadata +27 -7
  33. data/lib/spaceship/app.rb +0 -125
  34. data/lib/spaceship/certificate.rb +0 -271
  35. data/lib/spaceship/device.rb +0 -100
  36. data/lib/spaceship/provisioning_profile.rb +0 -367
File without changes
@@ -0,0 +1,28 @@
1
+ module Spaceship
2
+ module Tunes
3
+ # Represents a screenshot hosted on iTunes Connect
4
+ class AppScreenshot
5
+
6
+ attr_accessor :thumbnail_url
7
+
8
+ attr_accessor :sort_order
9
+
10
+ attr_accessor :original_file_name
11
+
12
+ attr_accessor :url
13
+
14
+ attr_accessor :device_type
15
+
16
+ attr_accessor :language
17
+
18
+ def initialize(hash)
19
+ self.thumbnail_url = hash[:thumbnail_url]
20
+ self.sort_order = hash[:sort_order]
21
+ self.original_file_name = hash[:original_file_name]
22
+ self.url = hash[:url]
23
+ self.device_type = hash[:device_type]
24
+ self.language = hash[:language]
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,63 @@
1
+ module Spaceship
2
+ module Tunes
3
+ # Defines the different states of the app
4
+ #
5
+ # As specified by Apple: https://developer.apple.com/library/ios/documentation/LanguagesUtilities/Conceptual/iTunesConnect_Guide/Chapters/ChangingAppStatus.html
6
+ module AppStatus
7
+ # You can edit this version, upload new binaries and more
8
+ PREPARE_FOR_SUBMISSION = "Prepare for Submission"
9
+
10
+ # App is currently live in the App Store
11
+ READY_FOR_SALE = "Ready for Sale"
12
+
13
+ # Waiting for Apple's Review
14
+ WAITING_FOR_REVIEW = "Waiting For Review"
15
+
16
+ # Currently in Review
17
+ IN_REVIEW = "In Review"
18
+
19
+ # App rejected for whatever reason
20
+ REJECTED = "Rejected"
21
+
22
+ # The developer took the app from the App Store
23
+ DEVELOPER_REMOVED_FROM_SALE = "Developer Removed From Sale"
24
+
25
+ # Developer rejected this version/binary
26
+ DEVELOPER_REJECTED = "Developer Rejected"
27
+
28
+ # You have to renew your Apple account to keep using iTunes Connect
29
+ PENDING_CONTRACT = "Pending Contract"
30
+
31
+ UPLOAD_RECEIVED = "Upload Received"
32
+ PENDING_DEVELOPER_RELEASE = "Pending Developer Release"
33
+ PROCESSING_FOR_APP_STORE = "Processing for App Store"
34
+
35
+ # Unused app states
36
+ # PENDING_APPLE_RELASE = "Pending Apple Release"
37
+
38
+ # WAITING_FOR_EXPORT_COMPLIANCE = "Waiting For Export Compliance"
39
+ # METADATA_REJECTED = "Metadata Rejected"
40
+ # REMOVED_FROM_SALE = "Removed From Sale"
41
+ # INVALID_BINARY = "Invalid Binary"
42
+
43
+
44
+ # Get the app status matching based on a string (given by iTunes Connect)
45
+ def self.get_from_string(text)
46
+ mapping = {
47
+ 'readyForSale' => READY_FOR_SALE,
48
+ 'prepareForUpload' => PREPARE_FOR_SUBMISSION,
49
+ 'devRejected' => DEVELOPER_REJECTED,
50
+ 'pendingContract' => PENDING_CONTRACT,
51
+ 'developerRemovedFromSale' => DEVELOPER_REMOVED_FROM_SALE,
52
+ 'waitingForReview' => WAITING_FOR_REVIEW
53
+ }
54
+
55
+ mapping.each do |k, v|
56
+ return v if k == text
57
+ end
58
+
59
+ return nil
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,149 @@
1
+ module Spaceship
2
+ module Tunes
3
+ # Represents a submission for review of an iTunes Connect Application
4
+ # This class handles the submission of all review information and documents
5
+ class AppSubmission < TunesBase
6
+ # @return (Spaceship::Tunes::Application) A reference to the application
7
+ # this submission is for
8
+ attr_accessor :application
9
+
10
+ # @return (AppVersion) The version to use for this submission
11
+ attr_accessor :version
12
+
13
+ # @return (String) The stage of this submission (start, complete)
14
+ attr_accessor :stage
15
+
16
+ # @return (Boolean) Submitted for Review
17
+ attr_accessor :submitted_for_review
18
+
19
+ # @return (Boolean) Ad ID Info - Limits ads tracking
20
+ attr_accessor :add_id_info_limits_tracking
21
+
22
+ # @return (Boolean) Ad ID Info - Serves ads
23
+ attr_accessor :add_id_info_serves_ads
24
+
25
+ # @return (Boolean) Ad ID Info - Tracks actions
26
+ attr_accessor :add_id_info_tracks_action
27
+
28
+ # @return (Boolean) Ad ID Info - Tracks installs
29
+ attr_accessor :add_id_info_tracks_install
30
+
31
+ # @return (Boolean) Ad ID Info - Uses idfa
32
+ attr_accessor :add_id_info_uses_idfa
33
+
34
+ # @return (Boolean) Content Rights - Contains third party content
35
+ attr_accessor :content_rights_contains_third_party_content
36
+
37
+ # @return (Boolean) Content Rights - Has rights of content
38
+ attr_accessor :content_rights_has_rights
39
+
40
+ # @return (Boolean) Export Compliance - Available on French Store
41
+ attr_accessor :export_compliance_available_on_french_store
42
+
43
+ # @return (@TODO) Export Compliance - CCAT File
44
+ attr_accessor :export_compliance_ccat_file
45
+
46
+ # @return (Boolean) Export Compliance - Contains proprietary cryptography
47
+ attr_accessor :export_compliance_contains_proprietary_cryptography
48
+
49
+ # @return (Boolean) Export Compliance - Contains third-party cryptography
50
+ attr_accessor :export_compliance_contains_third_party_cryptography
51
+
52
+ # @return (Boolean) Export Compliance - Is exempt
53
+ attr_accessor :export_compliance_is_exempt
54
+
55
+ # @return (Boolean) Export Compliance - Uses encryption
56
+ attr_accessor :export_compliance_uses_encryption
57
+
58
+ # @return (String) Export Compliance - App type
59
+ attr_accessor :export_compliance_app_type
60
+
61
+ # @return (Boolean) Export Compliance - Encryption Updated
62
+ attr_accessor :export_compliance_encryption_updated
63
+
64
+ # @return (Boolean) Export Compliance - Compliance Required
65
+ attr_accessor :export_compliance_compliance_required
66
+
67
+ # @return (String) Export Compliance - Platform
68
+ attr_accessor :export_compliance_platform
69
+
70
+ attr_mapping({
71
+
72
+ # Ad ID Info Section
73
+ 'adIdInfo.limitsTracking.value' => :add_id_info_limits_tracking,
74
+ 'adIdInfo.servesAds.value' => :add_id_info_serves_ads,
75
+ 'adIdInfo.tracksAction.value' => :add_id_info_tracks_action,
76
+ 'adIdInfo.tracksInstall.value' => :add_id_info_tracks_install,
77
+ 'adIdInfo.usesIdfa.value' => :add_id_info_uses_idfa,
78
+
79
+ # Content Rights Section
80
+ 'contentRights.containsThirdPartyContent.value' => :content_rights_contains_third_party_content,
81
+ 'contentRights.hasRights.value' => :content_rights_has_rights,
82
+
83
+ # Export Compliance Section
84
+ 'exportCompliance.availableOnFrenchStore.value' => :export_compliance_available_on_french_store,
85
+ 'exportCompliance.ccatFile.value' => :export_compliance_ccat_file,
86
+ 'exportCompliance.containsProprietaryCryptography.value' => :export_compliance_contains_proprietary_cryptography,
87
+ 'exportCompliance.containsThirdPartyCryptography.value' => :export_compliance_contains_third_party_cryptography,
88
+ 'exportCompliance.isExempt.value' => :export_compliance_is_exempt,
89
+ 'exportCompliance.usesEncryption.value' => :export_compliance_uses_encryption,
90
+ 'exportCompliance.appType' => :export_compliance_app_type,
91
+ 'exportCompliance.encryptionUpdated' => :export_compliance_encryption_updated,
92
+ 'exportCompliance.exportComplianceRequired' => :export_compliance_compliance_required,
93
+ 'exportCompliance.platform' => :export_compliance_platform
94
+ })
95
+
96
+ class << self
97
+ # Create a new object based on a hash.
98
+ # This is used to create a new object based on the server response.
99
+ def factory(attrs)
100
+ orig = attrs.dup
101
+
102
+ # fill content rights section if iTC returns nil
103
+ if attrs["contentRights"].nil?
104
+ attrs.merge!("contentRights" => {
105
+ "containsThirdPartyContent" => {
106
+ "value" => nil
107
+ },
108
+ "hasRights" => {
109
+ "value" => nil
110
+ }
111
+ })
112
+ end
113
+
114
+ obj = self.new(attrs)
115
+ return obj
116
+ end
117
+
118
+ # @param application (Spaceship::Tunes::Application) The app this submission is for
119
+ # @param app_id (String) The unique Apple ID of this app
120
+ def create(application, app_id, version)
121
+ stage = "start"
122
+ attrs = client.send_app_submission(application.apple_id, version.raw_data, stage)
123
+ attrs.merge!(application: application)
124
+ attrs.merge!(version: version)
125
+ attrs.merge!(stage: stage)
126
+
127
+ return self.factory(attrs)
128
+ end
129
+ end
130
+
131
+ # Save and complete the app submission
132
+ def complete!
133
+ @stage = "complete"
134
+ client.send_app_submission(application.apple_id, raw_data, @stage)
135
+ @submitted_for_review = true
136
+ end
137
+
138
+ # @return (String) An URL to this specific resource. You can enter this URL into your browser
139
+ def url
140
+ "https://itunesconnect.apple.com/WebObjects/iTunesConnect.woa/ra/apps/#{self.application.apple_id}/version/submit/#{self.stage}"
141
+ end
142
+
143
+ def setup
144
+ @submitted_for_review = false
145
+ end
146
+
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,337 @@
1
+ module Spaceship
2
+ module Tunes
3
+ # Represents an editable version of an iTunes Connect Application
4
+ # This can either be the live or the edit version retrieved via the app
5
+ class AppVersion < TunesBase
6
+ # @return (Spaceship::Tunes::Application) A reference to the application
7
+ # this version is for
8
+ attr_accessor :application
9
+
10
+ # @return (String) The version number of this version
11
+ attr_accessor :version
12
+
13
+ # @return (String) The copyright information of this app
14
+ attr_accessor :copyright
15
+
16
+ # @return (Spaceship::Tunes::AppStatus) What's the current status of this app
17
+ # e.g. Waiting for Review, Ready for Sale, ...
18
+ attr_reader :app_status
19
+
20
+ # @return (Bool) Is that the version that's currently available in the App Store?
21
+ attr_accessor :is_live
22
+
23
+ # Categories (e.g. MZGenre.Business)
24
+ attr_accessor :primary_category
25
+
26
+ attr_accessor :primary_first_sub_category
27
+
28
+ attr_accessor :primary_second_sub_category
29
+
30
+ attr_accessor :secondary_category
31
+
32
+ attr_accessor :secondary_first_sub_category
33
+
34
+ attr_accessor :secondary_second_sub_category
35
+
36
+ # @return (String) App Status (e.g. 'readyForSale'). You should use `app_status` instead
37
+ attr_accessor :raw_status
38
+
39
+ # @return (Bool)
40
+ attr_accessor :can_reject_version
41
+
42
+ # @return (Bool)
43
+ attr_accessor :can_prepare_for_upload
44
+
45
+ # @return (Bool)
46
+ attr_accessor :can_send_version_live
47
+
48
+ # @return (Bool) Should the app automatically be released once it's approved?
49
+ attr_accessor :release_on_approval
50
+
51
+ # @return (Bool)
52
+ attr_accessor :can_beta_test
53
+
54
+ # @return (Bool) Does the binary contain a watch binary?
55
+ attr_accessor :supports_apple_watch
56
+
57
+ # @return (String) URL to the full resolution 1024x1024 app icon
58
+ attr_accessor :app_icon_url
59
+
60
+ # @return (String) Name of the original file
61
+ attr_accessor :app_icon_original_name
62
+
63
+ # @return (String) URL to the full resolution 1024x1024 app icon
64
+ attr_accessor :watch_app_icon_url
65
+
66
+ # @return (String) Name of the original file
67
+ attr_accessor :watch_app_icon_original_name
68
+
69
+ # @return (Integer) a unqiue ID for this version generated by iTunes Connect
70
+ attr_accessor :version_id
71
+
72
+ # @return TODO
73
+ attr_accessor :company_information
74
+
75
+ ####
76
+ # App Review Information
77
+ ####
78
+ # @return (String) App Review Information First Name
79
+ attr_accessor :review_first_name
80
+
81
+ # @return (String) App Review Information Last Name
82
+ attr_accessor :review_last_name
83
+
84
+ # @return (String) App Review Information Phone Number
85
+ attr_accessor :review_phone_number
86
+
87
+ # @return (String) App Review Information Email Address
88
+ attr_accessor :review_email
89
+
90
+ # @return (String) App Review Information Demo Account User Name
91
+ attr_accessor :review_demo_user
92
+
93
+ # @return (String) App Review Information Demo Account Password
94
+ attr_accessor :review_demo_password
95
+
96
+ # @return (String) App Review Information Notes
97
+ attr_accessor :review_notes
98
+
99
+ ####
100
+ # Localized values:
101
+ ####
102
+
103
+ # @return (Array) Raw access the all available languages. You shouldn't use it probbaly
104
+ attr_accessor :languages
105
+
106
+ # @return (Hash) A hash representing the app name in all languages
107
+ attr_reader :name
108
+
109
+ # @return (Hash) A hash representing the keywords in all languages
110
+ attr_reader :keywords
111
+
112
+ # @return (Hash) A hash representing the description in all languages
113
+ attr_reader :description
114
+
115
+ # @return (Hash) The changelog
116
+ attr_reader :release_notes
117
+
118
+ # @return (Hash) A hash representing the keywords in all languages
119
+ attr_reader :privacy_url
120
+
121
+ # @return (Hash) A hash representing the keywords in all languages
122
+ attr_reader :support_url
123
+
124
+ # @return (Hash) A hash representing the keywords in all languages
125
+ attr_reader :marketing_url
126
+
127
+ # @return (Hash) Represents the screenshots of this app version (read-only)
128
+ attr_reader :screenshots
129
+
130
+
131
+ attr_mapping({
132
+ 'canBetaTest' => :can_beta_test,
133
+ 'canPrepareForUpload' => :can_prepare_for_upload,
134
+ 'canRejectVersion' => :can_reject_version,
135
+ 'canSendVersionLive' => :can_send_version_live,
136
+ 'copyright.value' => :copyright,
137
+ 'details.value' => :languages,
138
+ 'largeAppIcon.value.originalFileName' => :app_icon_original_name,
139
+ 'largeAppIcon.value.url' => :app_icon_url,
140
+ 'primaryCategory.value' => :primary_category,
141
+ 'primaryFirstSubCategory.value' => :primary_first_sub_category,
142
+ 'primarySecondSubCategory.value' => :primary_second_sub_category,
143
+ 'releaseOnApproval.value' => :release_on_approval,
144
+ 'secondaryCategory.value' => :secondary_category,
145
+ 'secondaryFirstSubCategory.value' => :secondary_first_sub_category,
146
+ 'secondarySecondSubCategory.value' => :secondary_second_sub_category,
147
+ 'status' => :raw_status,
148
+ 'supportsAppleWatch' => :supports_apple_watch,
149
+ 'versionId' => :version_id,
150
+ 'version.value' => :version,
151
+ 'watchAppIcon.value.originalFileName' => :watch_app_icon_original_name,
152
+ 'watchAppIcon.value.url' => :watch_app_icon_url,
153
+
154
+ # App Review Information
155
+ 'appReviewInfo.firstName.value' => :review_first_name,
156
+ 'appReviewInfo.lastName.value' => :review_last_name,
157
+ 'appReviewInfo.phoneNumber.value' => :review_phone_number,
158
+ 'appReviewInfo.emailAddress.value' => :review_email,
159
+ 'appReviewInfo.reviewNotes.value' => :review_notes,
160
+ 'appReviewInfo.userName.value' => :review_demo_user,
161
+ 'appReviewInfo.password.value' => :review_demo_password
162
+ })
163
+
164
+ class << self
165
+ # Create a new object based on a hash.
166
+ # This is used to create a new object based on the server response.
167
+ def factory(attrs)
168
+ orig = attrs.dup
169
+ obj = self.new(attrs)
170
+ obj.unfold_languages
171
+
172
+ return obj
173
+ end
174
+
175
+ # @param application (Spaceship::Tunes::Application) The app this version is for
176
+ # @param app_id (String) The unique Apple ID of this app
177
+ # @param is_live (Boolean) Is that the version that's live in the App Store?
178
+ def find(application, app_id, is_live = false)
179
+ attrs = client.app_version(app_id, is_live)
180
+ attrs.merge!(application: application)
181
+ attrs.merge!(is_live: is_live)
182
+
183
+ return self.factory(attrs)
184
+ end
185
+ end
186
+
187
+ # @return (Bool) Is that version currently available in the App Store?
188
+ def is_live?
189
+ is_live
190
+ end
191
+
192
+ # Call this method to make sure the given languages are available for this app
193
+ # You should call this method before accessing the name, description and other localized values
194
+ # This will create the new language if it's not available yet and do nothing if everything's there
195
+ # def create_languages!(languages)
196
+ # raise "Please pass an array" unless languages.kind_of?Array
197
+
198
+ # copy_from = self.languages.first
199
+ # languages.each do |language|
200
+ # # First, see if it's already available
201
+ # found = self.languages.find do |local|
202
+ # local['language'] == language
203
+ # end
204
+
205
+ # unless found
206
+ # new_language = copy_from.dup
207
+ # new_language['language'] = language
208
+ # [:description, :releaseNotes, :keywords].each do |key|
209
+ # new_language[key.to_s]['value'] = nil
210
+ # end
211
+
212
+ # self.languages << new_language
213
+ # unfold_languages
214
+
215
+ # # Now we need to set a useless `pageLanguageValue` value because iTC says so, after adding a new version
216
+ # self.languages.each do |current|
217
+ # current['pageLanguageValue'] = current['language']
218
+ # end
219
+ # end
220
+ # end
221
+
222
+ # languages
223
+ # end
224
+
225
+ # Push all changes that were made back to iTunes Connect
226
+ def save!
227
+ client.update_app_version!(application.apple_id, is_live?, raw_data)
228
+ end
229
+
230
+ # @return (String) An URL to this specific resource. You can enter this URL into your browser
231
+ def url
232
+ "https://itunesconnect.apple.com/WebObjects/iTunesConnect.woa/ra/ng/app/#{self.application.apple_id}/" + (self.is_live? ? "cur" : "")
233
+ end
234
+
235
+
236
+ # Private methods
237
+ def setup
238
+ # Properly parse the AppStatus
239
+ status = raw_data['status']
240
+ @app_status = Tunes::AppStatus.get_from_string(status)
241
+
242
+ # Setup the screenshots
243
+ @screenshots = {}
244
+ raw_data['details']['value'].each do |row|
245
+ # Now that's one language right here
246
+ @screenshots[row['language']] = setup_screenshots(row)
247
+ end
248
+ end
249
+
250
+
251
+ # Prefill name, keywords, etc...
252
+ def unfold_languages
253
+ {
254
+ name: :name,
255
+ keywords: :keywords,
256
+ description: :description,
257
+ privacyURL: :privacy_url,
258
+ supportURL: :support_url,
259
+ marketingURL: :marketing_url,
260
+ releaseNotes: :release_notes
261
+ }.each do |json, attribute|
262
+ instance_variable_set("@#{attribute}".to_sym, LanguageItem.new(json, languages))
263
+ end
264
+ end
265
+
266
+ # These methods takes care of properly parsing values that
267
+ # are not returned in the right format, e.g. boolean as string
268
+ def release_on_approval
269
+ super == 'true'
270
+ end
271
+
272
+ def supports_apple_watch
273
+ (super != nil)
274
+ end
275
+
276
+ def primary_category=(value)
277
+ value = "MZGenre.#{value}" unless value.include?"MZGenre"
278
+ super(value)
279
+ end
280
+
281
+ def primary_category=(value)
282
+ value = "MZGenre.#{value}" unless value.include?"MZGenre"
283
+ super(value)
284
+ end
285
+
286
+ def primary_first_sub_cate=(value)gory
287
+ value = "MZGenre.#{value}" unless value.include?"MZGenre"
288
+ super(value)
289
+ end
290
+
291
+ def primary_second_sub_cat=(value)egory
292
+ value = "MZGenre.#{value}" unless value.include?"MZGenre"
293
+ super(value)
294
+ end
295
+
296
+ def secondary_category=(value)
297
+ value = "MZGenre.#{value}" unless value.include?"MZGenre"
298
+ super(value)
299
+ end
300
+
301
+ def secondary_first_sub_ca=(value)tegory
302
+ value = "MZGenre.#{value}" unless value.include?"MZGenre"
303
+ super(value)
304
+ end
305
+
306
+ def secondary_second_sub_c=(value)ategory
307
+ value = "MZGenre.#{value}" unless value.include?"MZGenre"
308
+ super(value)
309
+ end
310
+
311
+ private
312
+ # generates the nested data structure to represent screenshots
313
+ def setup_screenshots(row)
314
+ screenshots = row.fetch('screenshots', {}).fetch('value', nil)
315
+ return [] unless screenshots
316
+
317
+ result = []
318
+
319
+ screenshots.each do |device_type, value|
320
+ value['value'].each do |screenshot|
321
+ screenshot = screenshot['value']
322
+ result << Tunes::AppScreenshot.new({
323
+ url: screenshot['url'],
324
+ thumbnail_url: screenshot['thumbNailUrl'],
325
+ sort_order: screenshot['sortOrder'],
326
+ original_file_name: screenshot['originalFileName'],
327
+ device_type: device_type,
328
+ language: row['language']
329
+ })
330
+ end
331
+ end
332
+
333
+ return result
334
+ end
335
+ end
336
+ end
337
+ end