wavesync 1.0.0.alpha3 → 1.0.0.beta1

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.
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'fileutils'
5
+ require 'tmpdir'
6
+ require 'yaml'
7
+ require_relative '../libmtp'
8
+ require_relative '../cue_chunk'
9
+ require_relative '../logger'
10
+
11
+ module Wavesync
12
+ module Transport
13
+ class Mtp
14
+ DEFAULT_CACHE_ROOT = File.join(Dir.home, '.cache', 'wavesync').freeze
15
+ MTP_ILLEGAL_CHARACTERS = %r{[<>:"/\\|?*\x00-\x1f]} #: Regexp
16
+ CACHE_DIR_ILLEGAL_CHARACTERS = /[^a-z0-9._-]/ #: Regexp
17
+ MANIFEST_FILENAME = '.manifest.yml'
18
+
19
+ attr_reader :working_directory #: String
20
+ attr_reader :device_path #: String
21
+ attr_reader :name #: String
22
+
23
+ #: (String name, ?cache_root: String) -> String
24
+ def self.cache_path(name, cache_root: DEFAULT_CACHE_ROOT)
25
+ File.join(cache_root, sanitize_dir_name(name))
26
+ end
27
+
28
+ #: (String name) -> String
29
+ def self.sanitize_dir_name(name)
30
+ sanitized = name.downcase.gsub(CACHE_DIR_ILLEGAL_CHARACTERS, '_')
31
+ sanitized.empty? ? 'device' : sanitized
32
+ end
33
+
34
+ #: ({ name: String, model: String, path: String, transport: String?, mp3_bitrate: Integer } device_config, ?libmtp: Libmtp, ?cache_root: String) -> void
35
+ def initialize(device_config, libmtp: Libmtp.new, cache_root: DEFAULT_CACHE_ROOT)
36
+ @name = device_config[:name]
37
+ @device_path = device_config[:path]
38
+ @libmtp = libmtp
39
+ @working_directory = self.class.cache_path(@name, cache_root: cache_root)
40
+ FileUtils.mkdir_p(@working_directory)
41
+ end
42
+
43
+ #: () ?{ (Integer, Integer, String) -> void } -> void
44
+ def prepare!(&progress)
45
+ device_files = @libmtp.files
46
+ folder_paths = build_folder_paths(@libmtp.folders)
47
+
48
+ candidates = device_files.filter_map do |device_file|
49
+ next unless wav?(device_file.filename)
50
+
51
+ relative_path = relative_path_for(device_file, folder_paths)
52
+ next unless relative_path
53
+
54
+ local_path = File.join(@working_directory, relative_path)
55
+ { device_file: device_file, relative_path: relative_path, local_path: local_path }
56
+ end
57
+
58
+ Dir.mktmpdir('wavesync_mtp_pull') do |tmpdir|
59
+ candidates.each_with_index do |candidate, index|
60
+ progress&.call(index, candidates.size, candidate[:relative_path])
61
+ pull_if_cues_differ(candidate, tmpdir)
62
+ end
63
+ end
64
+ end
65
+
66
+ #: () -> void
67
+ def begin_push!
68
+ @push_files_by_parent = @libmtp.files.group_by(&:parent_id)
69
+ @push_folders_by_parent = @libmtp.folders.group_by(&:parent_id)
70
+ @push_storage_id = primary_storage_id(@push_folders_by_parent)
71
+ @push_manifest = load_manifest
72
+ end
73
+
74
+ #: (String relative_path) -> void
75
+ def push_file!(relative_path)
76
+ files_by_parent = @push_files_by_parent
77
+ folders_by_parent = @push_folders_by_parent
78
+ storage_id = @push_storage_id
79
+ raise Libmtp::Error, 'push_file! called before begin_push!' unless files_by_parent && folders_by_parent && storage_id
80
+
81
+ local_path = File.join(@working_directory, relative_path)
82
+ return unless File.file?(local_path)
83
+
84
+ remote_path = join_remote_paths(@device_path, relative_path)
85
+ entry = { local_path: local_path, relative_path: relative_path, remote_path: remote_path } #: { local_path: String, relative_path: String, remote_path: String }
86
+ push_one(entry, files_by_parent: files_by_parent, folders_by_parent: folders_by_parent, storage_id: storage_id)
87
+ end
88
+
89
+ #: () -> void
90
+ def finish_push!
91
+ save_manifest if @push_manifest
92
+ @push_files_by_parent = nil
93
+ @push_folders_by_parent = nil
94
+ @push_storage_id = nil
95
+ @push_manifest = nil
96
+ @libmtp.close!
97
+ end
98
+
99
+ #: () ?{ (Integer, Integer, String) -> void } -> void
100
+ def commit!(&progress)
101
+ begin_push!
102
+ local_files = enumerate_local_files
103
+ local_files.each_with_index do |entry, index|
104
+ progress&.call(index, local_files.size, entry[:relative_path])
105
+ push_file!(entry[:relative_path])
106
+ end
107
+ finish_push!
108
+ end
109
+
110
+ private
111
+
112
+ #: () -> Array[{ local_path: String, relative_path: String, remote_path: String }]
113
+ def enumerate_local_files
114
+ Dir.glob(File.join(@working_directory, '**', '*'))
115
+ .reject { |path| File.directory?(path) }
116
+ .sort
117
+ .map do |local_path|
118
+ relative_path = local_path.sub(%r{\A#{Regexp.escape(@working_directory)}/?}, '')
119
+ remote_path = join_remote_paths(@device_path, relative_path)
120
+ { local_path: local_path, relative_path: relative_path, remote_path: remote_path }
121
+ end
122
+ end
123
+
124
+ #: ({ local_path: String, relative_path: String, remote_path: String } entry, files_by_parent: Hash[Integer, Array[Libmtp::DeviceFile]], folders_by_parent: Hash[Integer, Array[Libmtp::DeviceFolder]], storage_id: Integer) -> void
125
+ def push_one(entry, files_by_parent:, folders_by_parent:, storage_id:)
126
+ target_dir = File.dirname(entry[:remote_path])
127
+ parent_id = ensure_folder(target_dir, folders_by_parent: folders_by_parent, storage_id: storage_id)
128
+
129
+ filename = sanitize_for_mtp(File.basename(entry[:remote_path]))
130
+ existing = (files_by_parent[parent_id] || []).find { |file| sanitize_for_mtp(file.filename) == filename }
131
+
132
+ local_size = File.size(entry[:local_path])
133
+ manifest = @push_manifest || {} #: Hash[String, Integer]
134
+ manifest_size = manifest[entry[:relative_path]]
135
+
136
+ if existing && (manifest_size == local_size || (manifest_size.nil? && existing.size == local_size))
137
+ manifest[entry[:relative_path]] = local_size
138
+ return
139
+ end
140
+
141
+ if existing
142
+ Logger.log_event("MTP replacing (size mismatch device=#{existing.size} local=#{local_size}): #{entry[:relative_path]}")
143
+ @libmtp.delete_file(id: existing.id)
144
+ files_by_parent[parent_id]&.delete(existing)
145
+ else
146
+ Logger.log_event("MTP uploading new file (#{local_size} bytes): #{entry[:relative_path]}")
147
+ end
148
+
149
+ @libmtp.send_file(
150
+ local_path: entry[:local_path],
151
+ remote_filename: filename,
152
+ parent_id: parent_id,
153
+ storage_id: storage_id
154
+ )
155
+ manifest[entry[:relative_path]] = local_size
156
+ end
157
+
158
+ #: (String remote_path, folders_by_parent: Hash[Integer, Array[Libmtp::DeviceFolder]], storage_id: Integer) -> Integer
159
+ def ensure_folder(remote_path, folders_by_parent:, storage_id:)
160
+ parent_id = 0
161
+ path_components(remote_path).each do |raw_name|
162
+ name = sanitize_for_mtp(raw_name)
163
+ children = folders_by_parent[parent_id] || []
164
+ existing = children.find { |folder| sanitize_for_mtp(folder.name) == name }
165
+ if existing
166
+ parent_id = existing.folder_id
167
+ else
168
+ new_id = @libmtp.create_folder(name: name, parent_id: parent_id, storage_id: storage_id)
169
+ new_folder = Libmtp::DeviceFolder.new(folder_id: new_id, name: name, parent_id: parent_id, storage_id: storage_id)
170
+ (folders_by_parent[parent_id] ||= []) << new_folder
171
+ parent_id = new_id
172
+ end
173
+ end
174
+ parent_id
175
+ end
176
+
177
+ #: (String name) -> String
178
+ def normalize_unicode(name)
179
+ name.unicode_normalize(:nfc)
180
+ end
181
+
182
+ #: () -> String
183
+ def manifest_path
184
+ File.join(@working_directory, MANIFEST_FILENAME)
185
+ end
186
+
187
+ #: () -> Hash[String, Integer]
188
+ def load_manifest
189
+ return {} unless File.exist?(manifest_path)
190
+
191
+ data = YAML.safe_load_file(manifest_path)
192
+ data.is_a?(Hash) ? data : {}
193
+ rescue Psych::SyntaxError
194
+ {}
195
+ end
196
+
197
+ #: () -> void
198
+ def save_manifest
199
+ manifest = @push_manifest
200
+ return unless manifest
201
+
202
+ File.write(manifest_path, manifest.to_yaml)
203
+ end
204
+
205
+ #: (String name) -> String
206
+ def sanitize_for_mtp(name)
207
+ normalize_unicode(name).gsub(MTP_ILLEGAL_CHARACTERS, '')
208
+ end
209
+
210
+ #: (Hash[Integer, Array[Libmtp::DeviceFolder]] folders_by_parent) -> Integer
211
+ def primary_storage_id(folders_by_parent)
212
+ first_storage = folders_by_parent.values.flatten.map(&:storage_id).compact.first
213
+ raise Libmtp::DeviceNotFound, 'No MTP device detected. Connect the device and unmount it from any other app.' unless first_storage
214
+
215
+ first_storage
216
+ end
217
+
218
+ #: (String path) -> Array[String]
219
+ def path_components(path)
220
+ path.split('/').reject { |component| component.empty? || component == '.' }
221
+ end
222
+
223
+ #: (String left, String right) -> String
224
+ def join_remote_paths(left, right)
225
+ "#{left}/#{right}".split('/').reject(&:empty?).join('/')
226
+ end
227
+
228
+ #: (Array[Libmtp::DeviceFolder] folders) -> Hash[Integer, String]
229
+ def build_folder_paths(folders)
230
+ by_id = {} #: Hash[Integer, Libmtp::DeviceFolder]
231
+ folders.each { |folder| by_id[folder.folder_id] = folder }
232
+ paths = { 0 => '' } #: Hash[Integer, String]
233
+ folders.each { |folder| resolve_folder_path(folder.folder_id, by_id, paths) }
234
+ paths
235
+ end
236
+
237
+ #: (Integer folder_id, Hash[Integer, Libmtp::DeviceFolder] by_id, Hash[Integer, String] paths) -> String?
238
+ def resolve_folder_path(folder_id, by_id, paths)
239
+ return paths[folder_id] if paths.key?(folder_id)
240
+
241
+ folder = by_id[folder_id]
242
+ return nil unless folder
243
+
244
+ parent_path = resolve_folder_path(folder.parent_id, by_id, paths) || ''
245
+ full_path = parent_path.empty? ? folder.name : "#{parent_path}/#{folder.name}"
246
+ paths[folder_id] = full_path
247
+ full_path
248
+ end
249
+
250
+ #: (Libmtp::DeviceFile device_file, Hash[Integer, String] folder_paths) -> String?
251
+ def relative_path_for(device_file, folder_paths)
252
+ folder_path = folder_paths[device_file.parent_id]
253
+ return nil unless folder_path
254
+
255
+ full_path = folder_path.empty? ? device_file.filename : "#{folder_path}/#{device_file.filename}"
256
+ device_root = path_components(@device_path).join('/')
257
+ return full_path if device_root.empty?
258
+ return full_path[(device_root.length + 1)..] if full_path.start_with?("#{device_root}/")
259
+
260
+ nil
261
+ end
262
+
263
+ #: (String filename) -> bool
264
+ def wav?(filename)
265
+ File.extname(filename).downcase == '.wav'
266
+ end
267
+
268
+ #: ({ device_file: Libmtp::DeviceFile, relative_path: String, local_path: String } candidate, String tmpdir) -> void
269
+ def pull_if_cues_differ(candidate, tmpdir)
270
+ device_file = candidate[:device_file]
271
+ local_path = candidate[:local_path]
272
+ tmp_path = File.join(tmpdir, "#{device_file.id}.wav")
273
+
274
+ @libmtp.get_file(id: device_file.id, local_path: tmp_path)
275
+
276
+ device_cues = CueChunk.read(tmp_path)
277
+ local_cues = File.exist?(local_path) ? CueChunk.read(local_path) : [] #: Array[{identifier: Integer, sample_offset: Integer, label: String?}]
278
+ return if CueChunk.same?(device_cues, local_cues)
279
+
280
+ FileUtils.mkdir_p(File.dirname(local_path))
281
+ FileUtils.mv(tmp_path, local_path)
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Wavesync
5
+ module Transport
6
+ SUPPORTED_KINDS = %w[filesystem mtp].freeze
7
+
8
+ #: ({ name: String, model: String, path: String, transport: String?, mp3_bitrate: Integer } device_config) -> (Filesystem | Mtp)
9
+ def self.for(device_config)
10
+ kind = device_config[:transport] || 'filesystem'
11
+ case kind
12
+ when 'filesystem' then Filesystem.new(device_config)
13
+ when 'mtp' then Mtp.new(device_config)
14
+ else raise ArgumentError, "Unsupported transport: #{kind.inspect}. Supported: #{SUPPORTED_KINDS.join(', ')}"
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ require_relative 'transport/filesystem'
21
+ require_relative 'transport/mtp'
data/lib/wavesync/ui.rb CHANGED
@@ -42,12 +42,12 @@ module Wavesync
42
42
  sticky(parts.join(' '), 0)
43
43
  end
44
44
 
45
- #: (AudioFormat source_format, AudioFormat target_format) -> void
46
- def conversion_progress(source_format, target_format)
45
+ #: (AudioFormat source_format, AudioFormat target_format, Integer mp3_bitrate) -> void
46
+ def conversion_progress(source_format, target_format, mp3_bitrate)
47
47
  effective = source_format.merge(target_format)
48
48
 
49
- source_info = audio_info(source_format.sample_rate, source_format.bit_depth)
50
- target_info = audio_info(effective.sample_rate, effective.bit_depth)
49
+ source_info = audio_info(source_format)
50
+ target_info = target_audio_info(effective, mp3_bitrate)
51
51
 
52
52
  formatted_line = in_color(
53
53
  "Converting #{source_format.file_type} (#{source_info}) ⇢ #{effective.file_type} (#{target_info})", :highlight
@@ -57,14 +57,15 @@ module Wavesync
57
57
 
58
58
  #: (AudioFormat source_format) -> void
59
59
  def copy(source_format)
60
- info = audio_info(source_format.sample_rate, source_format.bit_depth)
60
+ info = audio_info(source_format)
61
61
 
62
62
  sticky(in_color("Copying #{source_format.file_type} (#{info})", :highlight), 3)
63
63
  end
64
64
 
65
- #: () -> void
66
- def skip
67
- sticky(in_color('↷ Skipping, already synced', :highlight), 3)
65
+ #: (?staged: bool) -> void
66
+ def skip(staged: false)
67
+ message = staged ? '↷ Already in cache' : '↷ Already on device'
68
+ sticky(in_color(message, :highlight), 3)
68
69
  end
69
70
 
70
71
  #: ((String | Integer)? tbpm, ?original_bars: Integer?, ?target_bars: Integer?) -> void
@@ -79,6 +80,26 @@ module Wavesync
79
80
  end
80
81
  end
81
82
 
83
+ #: (Integer index, Integer total_count, Device device) -> void
84
+ def pull_progress(index, total_count, device)
85
+ parts = [
86
+ in_color("wavesync pull #{device.name}", :primary),
87
+ in_color("#{index + 1}/#{total_count}", :extra)
88
+ ]
89
+
90
+ sticky(parts.join(' '), 0)
91
+ end
92
+
93
+ #: (Integer index, Integer total_count, Device device) -> void
94
+ def pull_staging_progress(index, total_count, device)
95
+ parts = [
96
+ in_color("wavesync pull #{device.name} (staging)", :primary),
97
+ in_color("#{index + 1}/#{total_count}", :extra)
98
+ ]
99
+
100
+ sticky(parts.join(' '), 0)
101
+ end
102
+
82
103
  #: (Integer index, Integer total_count) -> void
83
104
  def analyze_progress(index, total_count)
84
105
  parts = [
@@ -124,11 +145,21 @@ module Wavesync
124
145
 
125
146
  private
126
147
 
127
- #: (Integer? sample_rate, Integer? bit_depth) -> String
128
- def audio_info(sample_rate, bit_depth)
148
+ #: (AudioFormat format) -> String
149
+ def audio_info(format)
150
+ quality = format.file_type == 'mp3' ? format.bitrate&.to_s : format.bit_depth&.to_s
151
+ [
152
+ sample_rate_to_khz(format.sample_rate),
153
+ quality
154
+ ].compact.join('/')
155
+ end
156
+
157
+ #: (AudioFormat format, Integer mp3_bitrate) -> String
158
+ def target_audio_info(format, mp3_bitrate)
159
+ quality = format.file_type == 'mp3' ? mp3_bitrate.to_s : format.bit_depth&.to_s
129
160
  [
130
- sample_rate_to_khz(sample_rate),
131
- bit_depth&.to_s
161
+ sample_rate_to_khz(format.sample_rate),
162
+ quality
132
163
  ].compact.join('/')
133
164
  end
134
165
 
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module Wavesync
5
- VERSION = '1.0.0.alpha3'
5
+ VERSION = '1.0.0.beta1'
6
6
  end
data/lib/wavesync.rb CHANGED
@@ -3,10 +3,12 @@
3
3
  module Wavesync
4
4
  end
5
5
 
6
+ require 'wavesync/logger'
6
7
  require 'wavesync/version'
7
8
  require 'wavesync/acid_chunk'
8
9
  require 'wavesync/cue_chunk'
9
10
  require 'wavesync/audio_format'
11
+ require 'wavesync/ffmpeg'
10
12
  require 'wavesync/audio'
11
13
  require 'wavesync/track_padding'
12
14
  require 'wavesync/config'
@@ -14,10 +16,12 @@ require 'wavesync/device'
14
16
  require 'wavesync/ui'
15
17
  require 'wavesync/path_resolver'
16
18
  require 'wavesync/file_converter'
19
+ require 'wavesync/libmtp'
20
+ require 'wavesync/transport'
17
21
  require 'wavesync/scanner'
18
22
  require 'wavesync/bpm_detector'
19
23
  require 'wavesync/analyzer'
20
- require 'wavesync/set'
21
- require 'wavesync/set_editor'
24
+ require 'wavesync/setlist'
25
+ require 'wavesync/setlist_editor'
22
26
  require 'wavesync/commands'
23
27
  require 'wavesync/cli'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wavesync
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0.alpha3
4
+ version: 1.0.0.beta1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andreas Zecher
@@ -37,34 +37,6 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '3.1'
40
- - !ruby/object:Gem::Dependency
41
- name: streamio-ffmpeg
42
- requirement: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - "~>"
45
- - !ruby/object:Gem::Version
46
- version: '3.0'
47
- type: :runtime
48
- prerelease: false
49
- version_requirements: !ruby/object:Gem::Requirement
50
- requirements:
51
- - - "~>"
52
- - !ruby/object:Gem::Version
53
- version: '3.0'
54
- - !ruby/object:Gem::Dependency
55
- name: taglib-ruby
56
- requirement: !ruby/object:Gem::Requirement
57
- requirements:
58
- - - "~>"
59
- - !ruby/object:Gem::Version
60
- version: '2.0'
61
- type: :runtime
62
- prerelease: false
63
- version_requirements: !ruby/object:Gem::Requirement
64
- requirements:
65
- - - "~>"
66
- - !ruby/object:Gem::Version
67
- version: '2.0'
68
40
  - !ruby/object:Gem::Dependency
69
41
  name: tty-cursor
70
42
  requirement: !ruby/object:Gem::Requirement
@@ -111,22 +83,31 @@ files:
111
83
  - lib/wavesync/cli.rb
112
84
  - lib/wavesync/commands.rb
113
85
  - lib/wavesync/commands/analyze.rb
86
+ - lib/wavesync/commands/clear_cache.rb
114
87
  - lib/wavesync/commands/command.rb
115
88
  - lib/wavesync/commands/help.rb
116
- - lib/wavesync/commands/set.rb
89
+ - lib/wavesync/commands/pull.rb
90
+ - lib/wavesync/commands/setlist.rb
117
91
  - lib/wavesync/commands/sync.rb
118
92
  - lib/wavesync/config.rb
119
93
  - lib/wavesync/cue_chunk.rb
120
94
  - lib/wavesync/device.rb
121
95
  - lib/wavesync/essentia_bpm_detector.rb
96
+ - lib/wavesync/ffmpeg.rb
97
+ - lib/wavesync/ffmpeg/probe.rb
122
98
  - lib/wavesync/file_converter.rb
99
+ - lib/wavesync/libmtp.rb
100
+ - lib/wavesync/logger.rb
123
101
  - lib/wavesync/path_resolver.rb
124
102
  - lib/wavesync/percival_bpm_detector.rb
125
103
  - lib/wavesync/python_venv.rb
126
104
  - lib/wavesync/scanner.rb
127
- - lib/wavesync/set.rb
128
- - lib/wavesync/set_editor.rb
105
+ - lib/wavesync/setlist.rb
106
+ - lib/wavesync/setlist_editor.rb
129
107
  - lib/wavesync/track_padding.rb
108
+ - lib/wavesync/transport.rb
109
+ - lib/wavesync/transport/filesystem.rb
110
+ - lib/wavesync/transport/mtp.rb
130
111
  - lib/wavesync/ui.rb
131
112
  - lib/wavesync/version.rb
132
113
  homepage: https://github.com/pixelate/wavesync
@@ -1,66 +0,0 @@
1
- # frozen_string_literal: true
2
- # rbs_inline: enabled
3
-
4
- require 'optparse'
5
-
6
- module Wavesync
7
- module Commands
8
- class Set < Command
9
- self.name = 'set'
10
- self.subcommands = [
11
- Subcommand.new(usage: 'set create NAME', description: 'Create a new track set'),
12
- Subcommand.new(usage: 'set edit NAME', description: 'Edit an existing track set'),
13
- Subcommand.new(usage: 'set list', description: 'List all track sets')
14
- ].freeze
15
-
16
- #: () -> void
17
- def run
18
- subcommand = ARGV.shift
19
-
20
- _options, config = parse_options(banner: 'Usage: wavesync set <subcommand> [options]')
21
-
22
- case subcommand
23
- when 'create'
24
- name = require_name('create')
25
- if Wavesync::Set.exists?(config.library, name)
26
- puts "Set '#{name}' already exists. Use 'wavesync set edit #{name}' to edit it."
27
- exit 1
28
- end
29
- set = Wavesync::Set.new(config.library, name)
30
- Wavesync::SetEditor.new(set, config.library).run
31
- when 'edit'
32
- name = require_name('edit')
33
- unless Wavesync::Set.exists?(config.library, name)
34
- puts "Set '#{name}' not found. Use 'wavesync set create #{name}' to create it."
35
- exit 1
36
- end
37
- set = Wavesync::Set.load(config.library, name)
38
- Wavesync::SetEditor.new(set, config.library).run
39
- when 'list'
40
- sets = Wavesync::Set.all(config.library)
41
- if sets.empty?
42
- puts 'No sets found.'
43
- else
44
- sets.each { |set| puts "#{set.name} (#{set.tracks.size} tracks)" }
45
- end
46
- else
47
- puts "Unknown subcommand: #{subcommand || '(none)'}"
48
- puts 'Available subcommands: create, edit, list'
49
- exit 1
50
- end
51
- end
52
-
53
- private
54
-
55
- #: (String subcommand) -> String
56
- def require_name(subcommand)
57
- name = ARGV.shift
58
- unless name
59
- puts "Usage: wavesync set #{subcommand} <name>"
60
- exit 1
61
- end
62
- name
63
- end
64
- end
65
- end
66
- end