dockedit 1.0.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.
- checksums.yaml +7 -0
- data/.rspec +4 -0
- data/CHANGELOG.md +8 -0
- data/Gemfile +12 -0
- data/Gemfile.lock +37 -0
- data/README.md +262 -0
- data/Rakefile +97 -0
- data/bin/dockedit +10 -0
- data/build_standalone.rb +88 -0
- data/dockedit +1461 -0
- data/lib/dockedit/app_finder.rb +46 -0
- data/lib/dockedit/cli.rb +166 -0
- data/lib/dockedit/commands.rb +372 -0
- data/lib/dockedit/constants.rb +44 -0
- data/lib/dockedit/dock.rb +98 -0
- data/lib/dockedit/folder_updater.rb +58 -0
- data/lib/dockedit/matcher.rb +136 -0
- data/lib/dockedit/parsers.rb +62 -0
- data/lib/dockedit/path_utils.rb +44 -0
- data/lib/dockedit/plist_reader.rb +158 -0
- data/lib/dockedit/plist_writer.rb +52 -0
- data/lib/dockedit/tile_factory.rb +189 -0
- data/lib/dockedit/version.rb +7 -0
- data/lib/dockedit.rb +25 -0
- metadata +82 -0
data/dockedit
ADDED
|
@@ -0,0 +1,1461 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# dockedit - A script to edit the macOS Dock
|
|
5
|
+
# Usage: dockedit <subcommand> [options] [args]
|
|
6
|
+
|
|
7
|
+
require 'rexml/document'
|
|
8
|
+
require 'fileutils'
|
|
9
|
+
require 'optparse'
|
|
10
|
+
require 'uri'
|
|
11
|
+
require 'stringio'
|
|
12
|
+
|
|
13
|
+
# frozen_string_literal: true
|
|
14
|
+
|
|
15
|
+
module DockEdit
|
|
16
|
+
# Current dockedit gem version.
|
|
17
|
+
VERSION = '1.0.0'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# frozen_string_literal: true
|
|
22
|
+
|
|
23
|
+
module DockEdit
|
|
24
|
+
# Constants used across the dockedit implementation.
|
|
25
|
+
module Constants
|
|
26
|
+
# Absolute path to the Dock preferences plist.
|
|
27
|
+
#
|
|
28
|
+
# In tests this constant is overridden to point at a temporary copy.
|
|
29
|
+
DOCK_PLIST = File.expand_path('~/Library/Preferences/com.apple.dock.plist')
|
|
30
|
+
|
|
31
|
+
# Mapping of short folder names to full filesystem paths.
|
|
32
|
+
#
|
|
33
|
+
# Keys are regular expressions matched against user input; values are
|
|
34
|
+
# expanded paths.
|
|
35
|
+
PATH_SHORTCUTS = {
|
|
36
|
+
/^desktop$/i => File.expand_path('~/Desktop'),
|
|
37
|
+
/^downloads$/i => File.expand_path('~/Downloads'),
|
|
38
|
+
/^(home|~)$/i => File.expand_path('~'),
|
|
39
|
+
/^library$/i => File.expand_path('~/Library'),
|
|
40
|
+
/^documents$/i => File.expand_path('~/Documents'),
|
|
41
|
+
/^(applications|apps)$/i => '/Applications',
|
|
42
|
+
/^sites$/i => File.expand_path('~/Sites')
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
# Mapping of show-as string aliases to integer values used in the plist.
|
|
46
|
+
#
|
|
47
|
+
# 1 = fan, 2 = grid, 3 = list, 4 = auto.
|
|
48
|
+
SHOW_AS_VALUES = {
|
|
49
|
+
'f' => 1, 'fan' => 1,
|
|
50
|
+
'g' => 2, 'grid' => 2,
|
|
51
|
+
'l' => 3, 'list' => 3,
|
|
52
|
+
'a' => 4, 'auto' => 4
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
# Mapping of display-as string aliases to integer values used in the plist.
|
|
56
|
+
#
|
|
57
|
+
# 0 = stack, 1 = folder.
|
|
58
|
+
DISPLAY_AS_VALUES = {
|
|
59
|
+
's' => 0, 'stack' => 0,
|
|
60
|
+
'f' => 1, 'folder' => 1
|
|
61
|
+
}.freeze
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# frozen_string_literal: true
|
|
67
|
+
|
|
68
|
+
module DockEdit
|
|
69
|
+
# Handles fuzzy matching for application names and Dock items.
|
|
70
|
+
#
|
|
71
|
+
# Matching is based on a simple scoring system that prefers exact matches,
|
|
72
|
+
# then "starts with", then substring and abbreviation matches.
|
|
73
|
+
class Matcher
|
|
74
|
+
# Calculate a fuzzy match score between two strings.
|
|
75
|
+
#
|
|
76
|
+
# A lower score means a better match. +nil+ indicates no match at all.
|
|
77
|
+
#
|
|
78
|
+
# @param query [String] User-entered search text.
|
|
79
|
+
# @param target [String] Candidate string to score against.
|
|
80
|
+
# @return [Integer, nil] Score where 0 is best, or +nil+ when there is no match.
|
|
81
|
+
def self.match_score(query, target)
|
|
82
|
+
return nil if target.nil? || target.empty?
|
|
83
|
+
|
|
84
|
+
query = query.downcase
|
|
85
|
+
target = target.downcase
|
|
86
|
+
|
|
87
|
+
# Exact match - best score
|
|
88
|
+
return 0 if target == query
|
|
89
|
+
|
|
90
|
+
# Starts with query
|
|
91
|
+
return 1 if target.start_with?(query)
|
|
92
|
+
|
|
93
|
+
# Contains match - score based on position
|
|
94
|
+
if target.include?(query)
|
|
95
|
+
return 2 + target.index(query)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Abbreviation match (e.g., "vscode" matches "Visual Studio Code")
|
|
99
|
+
query_chars = query.chars
|
|
100
|
+
target_pos = 0
|
|
101
|
+
matched = true
|
|
102
|
+
|
|
103
|
+
query_chars.each do |char|
|
|
104
|
+
found = false
|
|
105
|
+
while target_pos < target.length
|
|
106
|
+
if target[target_pos] == char
|
|
107
|
+
found = true
|
|
108
|
+
target_pos += 1
|
|
109
|
+
break
|
|
110
|
+
end
|
|
111
|
+
target_pos += 1
|
|
112
|
+
end
|
|
113
|
+
unless found
|
|
114
|
+
matched = false
|
|
115
|
+
break
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
return 100 + target.length if matched
|
|
120
|
+
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Check whether +target+ fuzzily matches +query+.
|
|
125
|
+
#
|
|
126
|
+
# @param query [String]
|
|
127
|
+
# @param target [String]
|
|
128
|
+
# @return [Boolean] +true+ if {#match_score} returns a non-nil score.
|
|
129
|
+
def self.fuzzy_match?(query, target)
|
|
130
|
+
!match_score(query, target).nil?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Find the best-matching item index in a Dock array.
|
|
134
|
+
#
|
|
135
|
+
# This scans a list of Dock tile `<dict>` elements and finds the index
|
|
136
|
+
# whose label, bundle identifier, or (optionally) URL path best matches
|
|
137
|
+
# +query+.
|
|
138
|
+
#
|
|
139
|
+
# @param items_array [Array<REXML::Element>] Array of tile `<dict>` elements.
|
|
140
|
+
# @param query [String] Search term (app name, bundle id, or path fragment).
|
|
141
|
+
# @param check_url [Boolean] Whether to also consider the folder URL path.
|
|
142
|
+
# @return [Array<(Integer, String)>] A pair of `[index, display_name]`, where
|
|
143
|
+
# +index+ is the best entry index or +nil+ when nothing matches.
|
|
144
|
+
def self.find_item_index(items_array, query, check_url: false)
|
|
145
|
+
best_score = nil
|
|
146
|
+
best_name = nil
|
|
147
|
+
best_index = nil
|
|
148
|
+
|
|
149
|
+
items_array.each_with_index do |item_dict, index|
|
|
150
|
+
next unless item_dict.is_a?(REXML::Element) && item_dict.name == 'dict'
|
|
151
|
+
|
|
152
|
+
tile_data = PlistReader.get_tile_data(item_dict)
|
|
153
|
+
next unless tile_data
|
|
154
|
+
|
|
155
|
+
file_label = PlistReader.get_tile_value(tile_data, 'file-label')
|
|
156
|
+
bundle_id = PlistReader.get_tile_value(tile_data, 'bundle-identifier')
|
|
157
|
+
|
|
158
|
+
scores = []
|
|
159
|
+
scores << match_score(query, file_label)
|
|
160
|
+
scores << match_score(query, bundle_id)
|
|
161
|
+
|
|
162
|
+
# For folders, also check URL path
|
|
163
|
+
if check_url
|
|
164
|
+
url_string = PlistReader.get_file_data_url(tile_data)
|
|
165
|
+
if url_string
|
|
166
|
+
# Extract path from file:// URL and decode
|
|
167
|
+
path = URI.decode_www_form_component(url_string.sub(%r{^file://}, '').chomp('/'))
|
|
168
|
+
basename = File.basename(path)
|
|
169
|
+
scores << match_score(query, path)
|
|
170
|
+
scores << match_score(query, basename)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
current_score = scores.compact.min
|
|
175
|
+
next unless current_score
|
|
176
|
+
|
|
177
|
+
name = file_label || bundle_id || 'Unknown'
|
|
178
|
+
name_length = name.length
|
|
179
|
+
|
|
180
|
+
if best_score.nil? || current_score < best_score ||
|
|
181
|
+
(current_score == best_score && name_length < (best_name&.length || 999))
|
|
182
|
+
best_score = current_score
|
|
183
|
+
best_name = name
|
|
184
|
+
best_index = index
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
[best_index, best_name]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Backwards-compatible alias for finding app indices.
|
|
192
|
+
#
|
|
193
|
+
# @param apps_array [Array<REXML::Element>] App tile `<dict>` elements.
|
|
194
|
+
# @param query [String] Search term.
|
|
195
|
+
# @return [Array<(Integer, String)>] See {#find_item_index}.
|
|
196
|
+
def self.find_app_index(apps_array, query)
|
|
197
|
+
find_item_index(apps_array, query, check_url: false)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# frozen_string_literal: true
|
|
204
|
+
|
|
205
|
+
module DockEdit
|
|
206
|
+
# Utility functions for handling user-supplied filesystem paths.
|
|
207
|
+
module PathUtils
|
|
208
|
+
include Constants
|
|
209
|
+
|
|
210
|
+
# Expand a path shortcut or user path into a full filesystem path.
|
|
211
|
+
#
|
|
212
|
+
# Recognizes shortcuts such as "desktop", "downloads", "home", "~",
|
|
213
|
+
# "applications", etc., falling back to +File.expand_path+.
|
|
214
|
+
#
|
|
215
|
+
# @param path [String] Raw path or shortcut.
|
|
216
|
+
# @return [String] Expanded absolute path with trailing slash removed.
|
|
217
|
+
def self.expand_path_shortcut(path)
|
|
218
|
+
PATH_SHORTCUTS.each do |pattern, expanded|
|
|
219
|
+
return expanded if path.match?(pattern)
|
|
220
|
+
end
|
|
221
|
+
# Not a shortcut, expand ~ and return
|
|
222
|
+
File.expand_path(path).chomp('/')
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Determine whether the given path refers to a folder (not an app bundle).
|
|
226
|
+
#
|
|
227
|
+
# This respects path shortcuts and returns +true+ only for existing
|
|
228
|
+
# directories that do not end in ".app".
|
|
229
|
+
#
|
|
230
|
+
# @param path [String] Raw path or shortcut.
|
|
231
|
+
# @return [Boolean]
|
|
232
|
+
def self.folder_path?(path)
|
|
233
|
+
expanded = expand_path_shortcut(path)
|
|
234
|
+
File.directory?(expanded) && !expanded.end_with?('.app')
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Check whether the given string looks like an explicit app bundle path.
|
|
238
|
+
#
|
|
239
|
+
# @param path [String]
|
|
240
|
+
# @return [Boolean] +true+ if the string ends with ".app" and contains a '/'.
|
|
241
|
+
def self.explicit_app_path?(path)
|
|
242
|
+
path.end_with?('.app') && path.include?('/')
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# frozen_string_literal: true
|
|
249
|
+
|
|
250
|
+
module DockEdit
|
|
251
|
+
# Handles reading and parsing Dock and app plist data.
|
|
252
|
+
class PlistReader
|
|
253
|
+
include Constants
|
|
254
|
+
|
|
255
|
+
# Extract the +tile-data+ `<dict>` from a Dock tile `<dict>`.
|
|
256
|
+
#
|
|
257
|
+
# @param app_dict [REXML::Element] Tile `<dict>` element.
|
|
258
|
+
# @return [REXML::Element, nil] The nested +tile-data+ dict, or +nil+.
|
|
259
|
+
def self.get_tile_data(app_dict)
|
|
260
|
+
current_key = nil
|
|
261
|
+
app_dict.elements.each do |elem|
|
|
262
|
+
if elem.name == 'key'
|
|
263
|
+
current_key = elem.text
|
|
264
|
+
elsif elem.name == 'dict' && current_key == 'tile-data'
|
|
265
|
+
return elem
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Look up a string value in a tile-data `<dict>`.
|
|
272
|
+
#
|
|
273
|
+
# @param tile_data [REXML::Element] Tile-data `<dict>`.
|
|
274
|
+
# @param key_name [String] Name of the key to retrieve.
|
|
275
|
+
# @return [String, nil] The associated string value, or +nil+.
|
|
276
|
+
def self.get_tile_value(tile_data, key_name)
|
|
277
|
+
current_key = nil
|
|
278
|
+
tile_data.elements.each do |elem|
|
|
279
|
+
if elem.name == 'key'
|
|
280
|
+
current_key = elem.text
|
|
281
|
+
elsif current_key == key_name
|
|
282
|
+
return elem.text if elem.name == 'string'
|
|
283
|
+
return nil
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
nil
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Extract the `_CFURLString` from the nested +file-data+ dict.
|
|
290
|
+
#
|
|
291
|
+
# @param tile_data [REXML::Element] Tile-data `<dict>`.
|
|
292
|
+
# @return [String, nil] The URL string, or +nil+ if none is present.
|
|
293
|
+
def self.get_file_data_url(tile_data)
|
|
294
|
+
current_key = nil
|
|
295
|
+
tile_data.elements.each do |elem|
|
|
296
|
+
if elem.name == 'key'
|
|
297
|
+
current_key = elem.text
|
|
298
|
+
elsif elem.name == 'dict' && current_key == 'file-data'
|
|
299
|
+
return get_tile_value(elem, '_CFURLString')
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
nil
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Get the `persistent-apps` array from a Dock plist document.
|
|
306
|
+
#
|
|
307
|
+
# @param doc [REXML::Document]
|
|
308
|
+
# @return [REXML::Element, nil] The `<array>` element, or +nil+.
|
|
309
|
+
def self.get_persistent_apps(doc)
|
|
310
|
+
get_plist_array(doc, 'persistent-apps')
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Get the `persistent-others` array from a Dock plist document.
|
|
314
|
+
#
|
|
315
|
+
# @param doc [REXML::Document]
|
|
316
|
+
# @return [REXML::Element, nil] The `<array>` element, or +nil+.
|
|
317
|
+
def self.get_persistent_others(doc)
|
|
318
|
+
get_plist_array(doc, 'persistent-others')
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Get a named array from the Dock plist root `<dict>`.
|
|
322
|
+
#
|
|
323
|
+
# @param doc [REXML::Document]
|
|
324
|
+
# @param array_name [String] Name of the array key.
|
|
325
|
+
# @return [REXML::Element, nil] The `<array>` element, or +nil+.
|
|
326
|
+
def self.get_plist_array(doc, array_name)
|
|
327
|
+
root_dict = doc.root.elements['dict']
|
|
328
|
+
return nil unless root_dict
|
|
329
|
+
|
|
330
|
+
current_key = nil
|
|
331
|
+
root_dict.elements.each do |elem|
|
|
332
|
+
if elem.name == 'key'
|
|
333
|
+
current_key = elem.text
|
|
334
|
+
elsif elem.name == 'array' && current_key == array_name
|
|
335
|
+
return elem
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
nil
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Load and parse the Dock plist as XML.
|
|
343
|
+
#
|
|
344
|
+
# The file at {DockEdit::Constants::DOCK_PLIST} is converted to XML form
|
|
345
|
+
# using +plutil+ before being read.
|
|
346
|
+
#
|
|
347
|
+
# @return [REXML::Document] Parsed Dock plist document.
|
|
348
|
+
def self.load_dock_plist
|
|
349
|
+
unless system("plutil -convert xml1 '#{DOCK_PLIST}' 2>/dev/null")
|
|
350
|
+
$stderr.puts "Error: Failed to convert Dock plist to XML"
|
|
351
|
+
exit 1
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
plist_content = File.read(DOCK_PLIST)
|
|
355
|
+
REXML::Document.new(plist_content)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Read and parse +Info.plist+ from an app bundle.
|
|
359
|
+
#
|
|
360
|
+
# The plist is copied to a temporary file and converted to XML before
|
|
361
|
+
# parsing. Selected keys are extracted into a flat Ruby hash.
|
|
362
|
+
#
|
|
363
|
+
# @param app_path [String] Absolute path to an `.app` bundle.
|
|
364
|
+
# @return [Hash, nil] Hash of plist keys to values, or +nil+ if the
|
|
365
|
+
# plist cannot be read.
|
|
366
|
+
def self.read_app_info(app_path)
|
|
367
|
+
info_plist = File.join(app_path, 'Contents', 'Info.plist')
|
|
368
|
+
return nil unless File.exist?(info_plist)
|
|
369
|
+
|
|
370
|
+
# Convert to XML and read
|
|
371
|
+
temp_plist = "/tmp/dockedit_info_#{$$}.plist"
|
|
372
|
+
FileUtils.cp(info_plist, temp_plist)
|
|
373
|
+
system("plutil -convert xml1 '#{temp_plist}' 2>/dev/null")
|
|
374
|
+
|
|
375
|
+
content = File.read(temp_plist)
|
|
376
|
+
File.delete(temp_plist) if File.exist?(temp_plist)
|
|
377
|
+
|
|
378
|
+
doc = REXML::Document.new(content)
|
|
379
|
+
root_dict = doc.root.elements['dict']
|
|
380
|
+
return nil unless root_dict
|
|
381
|
+
|
|
382
|
+
info = {}
|
|
383
|
+
current_key = nil
|
|
384
|
+
|
|
385
|
+
root_dict.elements.each do |elem|
|
|
386
|
+
if elem.name == 'key'
|
|
387
|
+
current_key = elem.text
|
|
388
|
+
elsif current_key
|
|
389
|
+
case elem.name
|
|
390
|
+
when 'string'
|
|
391
|
+
info[current_key] = elem.text
|
|
392
|
+
when 'array'
|
|
393
|
+
# For arrays, get first string element
|
|
394
|
+
first_string = elem.elements['string']
|
|
395
|
+
info[current_key] = first_string.text if first_string
|
|
396
|
+
end
|
|
397
|
+
current_key = nil
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
info
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# frozen_string_literal: true
|
|
408
|
+
|
|
409
|
+
module DockEdit
|
|
410
|
+
# Handles writing Dock plist data and restarting the Dock process.
|
|
411
|
+
class PlistWriter
|
|
412
|
+
include Constants
|
|
413
|
+
|
|
414
|
+
# Write the modified Dock plist and restart the Dock.
|
|
415
|
+
#
|
|
416
|
+
# The provided document is pretty-printed, written to
|
|
417
|
+
# {DockEdit::Constants::DOCK_PLIST}, converted back to binary format,
|
|
418
|
+
# and the Dock process is restarted. Success messages are printed and
|
|
419
|
+
# the process exits with status 0. On error, a message is printed and
|
|
420
|
+
# the process exits with status 1.
|
|
421
|
+
#
|
|
422
|
+
# @param doc [REXML::Document] Modified Dock plist document.
|
|
423
|
+
# @param success_messages [String, Array<String>] Message or messages to
|
|
424
|
+
# print after a successful write.
|
|
425
|
+
# @return [void]
|
|
426
|
+
def self.write_plist_and_restart(doc, success_messages)
|
|
427
|
+
formatter = REXML::Formatters::Pretty.new(2)
|
|
428
|
+
formatter.compact = true
|
|
429
|
+
|
|
430
|
+
output = StringIO.new
|
|
431
|
+
output << %{<?xml version="1.0" encoding="UTF-8"?>\n}
|
|
432
|
+
output << %{<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n}
|
|
433
|
+
formatter.write(doc.root, output)
|
|
434
|
+
output << "\n"
|
|
435
|
+
|
|
436
|
+
begin
|
|
437
|
+
File.write(DOCK_PLIST, output.string)
|
|
438
|
+
rescue => e
|
|
439
|
+
$stderr.puts "Error: Failure to update Dock plist - #{e.message}"
|
|
440
|
+
exit 1
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Convert back to binary and restart Dock
|
|
444
|
+
unless system("plutil -convert binary1 '#{DOCK_PLIST}' 2>/dev/null")
|
|
445
|
+
$stderr.puts "Error: Failure to update Dock plist"
|
|
446
|
+
exit 1
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
system('killall Dock')
|
|
450
|
+
|
|
451
|
+
# Handle single message or array of messages
|
|
452
|
+
messages = success_messages.is_a?(Array) ? success_messages : [success_messages]
|
|
453
|
+
messages.each { |msg| puts msg }
|
|
454
|
+
exit 0
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# frozen_string_literal: true
|
|
461
|
+
|
|
462
|
+
module DockEdit
|
|
463
|
+
# Handles finding applications on disk using Spotlight (mdfind).
|
|
464
|
+
#
|
|
465
|
+
# This class is used by commands that accept an application name (e.g. "Safari")
|
|
466
|
+
# and need to resolve it to a full `.app` bundle path.
|
|
467
|
+
class AppFinder
|
|
468
|
+
# Find an application bundle on disk using Spotlight.
|
|
469
|
+
#
|
|
470
|
+
# The search is limited to `/Applications` and `/System/Applications`.
|
|
471
|
+
# Results are scored using {DockEdit::Matcher.match_score} and the best
|
|
472
|
+
# matching `.app` path is returned.
|
|
473
|
+
#
|
|
474
|
+
# @param query [String] Human-friendly app name to search for.
|
|
475
|
+
# @return [String, nil] Absolute path to the best matching `.app`, or +nil+
|
|
476
|
+
# if no match is found.
|
|
477
|
+
def self.find_app_on_disk(query)
|
|
478
|
+
# Search in /Applications and /System/Applications
|
|
479
|
+
result = `mdfind -onlyin /Applications -onlyin /System/Applications 'kMDItemKind == "Application" && kMDItemDisplayName == "*#{query}*"cd' 2>/dev/null`.strip
|
|
480
|
+
|
|
481
|
+
if result.empty?
|
|
482
|
+
# Fallback to filename search
|
|
483
|
+
result = `mdfind -onlyin /Applications -onlyin /System/Applications 'kMDItemFSName == "*#{query}*.app"cd' 2>/dev/null`.strip
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
return nil if result.empty?
|
|
487
|
+
|
|
488
|
+
apps = result.split("\n").select { |p| p.end_with?('.app') }
|
|
489
|
+
return nil if apps.empty?
|
|
490
|
+
|
|
491
|
+
# Score and sort by best match (shortest name wins on equal score)
|
|
492
|
+
scored = apps.map do |path|
|
|
493
|
+
name = File.basename(path, '.app')
|
|
494
|
+
score = Matcher.match_score(query, name)
|
|
495
|
+
[path, name, score || 999, name.length]
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# Sort by score, then by name length
|
|
499
|
+
scored.sort_by! { |_, _, score, len| [score, len] }
|
|
500
|
+
|
|
501
|
+
scored.first&.first
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# frozen_string_literal: true
|
|
508
|
+
|
|
509
|
+
module DockEdit
|
|
510
|
+
# Factory for creating Dock tile elements.
|
|
511
|
+
#
|
|
512
|
+
# These helpers construct the REXML structures that represent apps,
|
|
513
|
+
# folders, and spacer tiles inside the Dock plist.
|
|
514
|
+
class TileFactory
|
|
515
|
+
# Create a spacer tile element.
|
|
516
|
+
#
|
|
517
|
+
# @param small [Boolean] When +true+, create a half-size spacer.
|
|
518
|
+
# @return [REXML::Element] Newly created tile `<dict>` element.
|
|
519
|
+
def self.create_spacer_tile(small: false)
|
|
520
|
+
tile_type = small ? 'small-spacer-tile' : 'spacer-tile'
|
|
521
|
+
|
|
522
|
+
spacer = REXML::Element.new('dict')
|
|
523
|
+
|
|
524
|
+
key1 = REXML::Element.new('key')
|
|
525
|
+
key1.text = 'tile-data'
|
|
526
|
+
spacer.add(key1)
|
|
527
|
+
|
|
528
|
+
tile_data = REXML::Element.new('dict')
|
|
529
|
+
label_key = REXML::Element.new('key')
|
|
530
|
+
label_key.text = 'file-label'
|
|
531
|
+
tile_data.add(label_key)
|
|
532
|
+
label_string = REXML::Element.new('string')
|
|
533
|
+
label_string.text = ''
|
|
534
|
+
tile_data.add(label_string)
|
|
535
|
+
spacer.add(tile_data)
|
|
536
|
+
|
|
537
|
+
key2 = REXML::Element.new('key')
|
|
538
|
+
key2.text = 'tile-type'
|
|
539
|
+
spacer.add(key2)
|
|
540
|
+
|
|
541
|
+
type_string = REXML::Element.new('string')
|
|
542
|
+
type_string.text = tile_type
|
|
543
|
+
spacer.add(type_string)
|
|
544
|
+
|
|
545
|
+
spacer
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Create an app tile element.
|
|
549
|
+
#
|
|
550
|
+
# @param app_path [String] Absolute path to the `.app` bundle.
|
|
551
|
+
# @param app_info [Hash] Parsed plist info from {PlistReader.read_app_info}.
|
|
552
|
+
# @return [REXML::Element] Newly created tile `<dict>` element.
|
|
553
|
+
def self.create_app_tile(app_path, app_info)
|
|
554
|
+
app_dict = REXML::Element.new('dict')
|
|
555
|
+
|
|
556
|
+
# tile-data key
|
|
557
|
+
td_key = REXML::Element.new('key')
|
|
558
|
+
td_key.text = 'tile-data'
|
|
559
|
+
app_dict.add(td_key)
|
|
560
|
+
|
|
561
|
+
# tile-data dict
|
|
562
|
+
tile_data = REXML::Element.new('dict')
|
|
563
|
+
|
|
564
|
+
# bundle-identifier
|
|
565
|
+
add_plist_key_value(tile_data, 'bundle-identifier', 'string', app_info['CFBundleIdentifier'])
|
|
566
|
+
|
|
567
|
+
# dock-extra
|
|
568
|
+
add_plist_key_value(tile_data, 'dock-extra', 'false', nil)
|
|
569
|
+
|
|
570
|
+
# file-data dict
|
|
571
|
+
fd_key = REXML::Element.new('key')
|
|
572
|
+
fd_key.text = 'file-data'
|
|
573
|
+
tile_data.add(fd_key)
|
|
574
|
+
|
|
575
|
+
file_data = REXML::Element.new('dict')
|
|
576
|
+
add_plist_key_value(file_data, '_CFURLString', 'string', "file://#{URI.encode_www_form_component(app_path).gsub('%2F', '/')}/")
|
|
577
|
+
add_plist_key_value(file_data, '_CFURLStringType', 'integer', '15')
|
|
578
|
+
tile_data.add(file_data)
|
|
579
|
+
|
|
580
|
+
# file-label
|
|
581
|
+
label = app_info['CFBundleName'] || app_info['CFBundleDisplayName'] || File.basename(app_path, '.app')
|
|
582
|
+
add_plist_key_value(tile_data, 'file-label', 'string', label)
|
|
583
|
+
|
|
584
|
+
# file-mod-date
|
|
585
|
+
add_plist_key_value(tile_data, 'file-mod-date', 'integer', '0')
|
|
586
|
+
|
|
587
|
+
# file-type
|
|
588
|
+
add_plist_key_value(tile_data, 'file-type', 'integer', '41')
|
|
589
|
+
|
|
590
|
+
# is-beta
|
|
591
|
+
add_plist_key_value(tile_data, 'is-beta', 'false', nil)
|
|
592
|
+
|
|
593
|
+
# parent-mod-date
|
|
594
|
+
add_plist_key_value(tile_data, 'parent-mod-date', 'integer', '0')
|
|
595
|
+
|
|
596
|
+
app_dict.add(tile_data)
|
|
597
|
+
|
|
598
|
+
# tile-type key
|
|
599
|
+
tt_key = REXML::Element.new('key')
|
|
600
|
+
tt_key.text = 'tile-type'
|
|
601
|
+
app_dict.add(tt_key)
|
|
602
|
+
|
|
603
|
+
tt_value = REXML::Element.new('string')
|
|
604
|
+
tt_value.text = 'file-tile'
|
|
605
|
+
app_dict.add(tt_value)
|
|
606
|
+
|
|
607
|
+
app_dict
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# Create a folder tile element.
|
|
611
|
+
#
|
|
612
|
+
# @param folder_path [String] Absolute path to the folder.
|
|
613
|
+
# @param show_as [Integer] Show-as value (1=fan, 2=grid, 3=list, 4=auto).
|
|
614
|
+
# @param display_as [Integer] Display-as value (0=stack, 1=folder).
|
|
615
|
+
# @return [REXML::Element] Newly created folder tile `<dict>` element.
|
|
616
|
+
def self.create_folder_tile(folder_path, show_as: 4, display_as: 1)
|
|
617
|
+
folder_dict = REXML::Element.new('dict')
|
|
618
|
+
|
|
619
|
+
# tile-data key
|
|
620
|
+
td_key = REXML::Element.new('key')
|
|
621
|
+
td_key.text = 'tile-data'
|
|
622
|
+
folder_dict.add(td_key)
|
|
623
|
+
|
|
624
|
+
# tile-data dict
|
|
625
|
+
tile_data = REXML::Element.new('dict')
|
|
626
|
+
|
|
627
|
+
# arrangement (0 = by name)
|
|
628
|
+
add_plist_key_value(tile_data, 'arrangement', 'integer', '0')
|
|
629
|
+
|
|
630
|
+
# displayas (0 = stack, 1 = folder)
|
|
631
|
+
add_plist_key_value(tile_data, 'displayas', 'integer', display_as.to_s)
|
|
632
|
+
|
|
633
|
+
# file-data dict
|
|
634
|
+
fd_key = REXML::Element.new('key')
|
|
635
|
+
fd_key.text = 'file-data'
|
|
636
|
+
tile_data.add(fd_key)
|
|
637
|
+
|
|
638
|
+
file_data = REXML::Element.new('dict')
|
|
639
|
+
encoded_path = URI.encode_www_form_component(folder_path).gsub('%2F', '/')
|
|
640
|
+
add_plist_key_value(file_data, '_CFURLString', 'string', "file://#{encoded_path}/")
|
|
641
|
+
add_plist_key_value(file_data, '_CFURLStringType', 'integer', '15')
|
|
642
|
+
tile_data.add(file_data)
|
|
643
|
+
|
|
644
|
+
# file-label
|
|
645
|
+
label = File.basename(folder_path)
|
|
646
|
+
add_plist_key_value(tile_data, 'file-label', 'string', label)
|
|
647
|
+
|
|
648
|
+
# file-mod-date
|
|
649
|
+
add_plist_key_value(tile_data, 'file-mod-date', 'integer', '0')
|
|
650
|
+
|
|
651
|
+
# file-type
|
|
652
|
+
add_plist_key_value(tile_data, 'file-type', 'integer', '2')
|
|
653
|
+
|
|
654
|
+
# parent-mod-date
|
|
655
|
+
add_plist_key_value(tile_data, 'parent-mod-date', 'integer', '0')
|
|
656
|
+
|
|
657
|
+
# preferreditemsize (-1 = default)
|
|
658
|
+
add_plist_key_value(tile_data, 'preferreditemsize', 'integer', '-1')
|
|
659
|
+
|
|
660
|
+
# showas (1=fan, 2=grid, 3=list, 4=auto)
|
|
661
|
+
add_plist_key_value(tile_data, 'showas', 'integer', show_as.to_s)
|
|
662
|
+
|
|
663
|
+
folder_dict.add(tile_data)
|
|
664
|
+
|
|
665
|
+
# tile-type key
|
|
666
|
+
tt_key = REXML::Element.new('key')
|
|
667
|
+
tt_key.text = 'tile-type'
|
|
668
|
+
folder_dict.add(tt_key)
|
|
669
|
+
|
|
670
|
+
tt_value = REXML::Element.new('string')
|
|
671
|
+
tt_value.text = 'directory-tile'
|
|
672
|
+
folder_dict.add(tt_value)
|
|
673
|
+
|
|
674
|
+
folder_dict
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
# Helper to add a key/value pair to a plist `<dict>` element.
|
|
678
|
+
#
|
|
679
|
+
# @param dict [REXML::Element] Target `<dict>` element.
|
|
680
|
+
# @param key_name [String] Key to add.
|
|
681
|
+
# @param value_type [String] Name of the value element type (e.g. "string").
|
|
682
|
+
# @param value [String, nil] Optional text value for the element.
|
|
683
|
+
# @return [void]
|
|
684
|
+
def self.add_plist_key_value(dict, key_name, value_type, value)
|
|
685
|
+
key = REXML::Element.new('key')
|
|
686
|
+
key.text = key_name
|
|
687
|
+
dict.add(key)
|
|
688
|
+
|
|
689
|
+
val_elem = REXML::Element.new(value_type)
|
|
690
|
+
val_elem.text = value if value
|
|
691
|
+
dict.add(val_elem)
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
# frozen_string_literal: true
|
|
698
|
+
|
|
699
|
+
module DockEdit
|
|
700
|
+
# Handles updating folder tile properties in the Dock plist.
|
|
701
|
+
#
|
|
702
|
+
# These helpers mutate existing folder tiles to change view and style
|
|
703
|
+
# without recreating them.
|
|
704
|
+
class FolderUpdater
|
|
705
|
+
# Update the +showas+ value for an existing folder tile.
|
|
706
|
+
#
|
|
707
|
+
# @param folder_dict [REXML::Element] Folder tile `<dict>` element.
|
|
708
|
+
# @param show_as [Integer] New show-as value (1=fan, 2=grid, 3=list, 4=auto).
|
|
709
|
+
# @return [Boolean] +true+ if the value was updated or added.
|
|
710
|
+
def self.update_folder_showas(folder_dict, show_as)
|
|
711
|
+
update_folder_integer_key(folder_dict, 'showas', show_as)
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
# Update the +displayas+ value for an existing folder tile.
|
|
715
|
+
#
|
|
716
|
+
# @param folder_dict [REXML::Element] Folder tile `<dict>` element.
|
|
717
|
+
# @param display_as [Integer] New display-as value (0=stack, 1=folder).
|
|
718
|
+
# @return [Boolean] +true+ if the value was updated or added.
|
|
719
|
+
def self.update_folder_displayas(folder_dict, display_as)
|
|
720
|
+
update_folder_integer_key(folder_dict, 'displayas', display_as)
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
# Update an integer key inside folder tile-data.
|
|
724
|
+
#
|
|
725
|
+
# If the key already exists its integer value is replaced; otherwise a new
|
|
726
|
+
# key/value pair is appended.
|
|
727
|
+
#
|
|
728
|
+
# @param folder_dict [REXML::Element] Folder tile `<dict>` element.
|
|
729
|
+
# @param key_name [String] Name of the integer key inside +tile-data+.
|
|
730
|
+
# @param value [Integer] New integer value to set.
|
|
731
|
+
# @return [Boolean] +true+ if the value was updated or added, +false+ if
|
|
732
|
+
# no +tile-data+ section could be found.
|
|
733
|
+
def self.update_folder_integer_key(folder_dict, key_name, value)
|
|
734
|
+
tile_data = PlistReader.get_tile_data(folder_dict)
|
|
735
|
+
return false unless tile_data
|
|
736
|
+
|
|
737
|
+
# Find and update the key
|
|
738
|
+
current_key = nil
|
|
739
|
+
tile_data.elements.each do |elem|
|
|
740
|
+
if elem.name == 'key' && elem.text == key_name
|
|
741
|
+
current_key = elem
|
|
742
|
+
elsif current_key && elem.name == 'integer'
|
|
743
|
+
elem.text = value.to_s
|
|
744
|
+
return true
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
# If key doesn't exist, add it
|
|
749
|
+
TileFactory.add_plist_key_value(tile_data, key_name, 'integer', value.to_s)
|
|
750
|
+
true
|
|
751
|
+
end
|
|
752
|
+
end
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
# frozen_string_literal: true
|
|
757
|
+
|
|
758
|
+
module DockEdit
|
|
759
|
+
# Utility functions for parsing and formatting Dock-related values.
|
|
760
|
+
module Parsers
|
|
761
|
+
include Constants
|
|
762
|
+
|
|
763
|
+
# Parse a show-as argument into an integer value.
|
|
764
|
+
#
|
|
765
|
+
# Accepts symbolic forms like "fan", "grid", "list", "auto" (and their
|
|
766
|
+
# single-letter aliases) and returns the corresponding integer constant.
|
|
767
|
+
#
|
|
768
|
+
# @param value [String, nil] Raw user input.
|
|
769
|
+
# @return [Integer, nil] Parsed show-as value, or +nil+ if +value+ is +nil+.
|
|
770
|
+
def self.parse_show_as(value)
|
|
771
|
+
return nil if value.nil?
|
|
772
|
+
|
|
773
|
+
key = value.downcase
|
|
774
|
+
SHOW_AS_VALUES[key] || 4
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# Convert a numeric show-as value into a human-readable name.
|
|
778
|
+
#
|
|
779
|
+
# @param value [Integer] Numeric show-as value.
|
|
780
|
+
# @return [String] One of "fan", "grid", "list", or "auto".
|
|
781
|
+
def self.show_as_name(value)
|
|
782
|
+
case value
|
|
783
|
+
when 1 then 'fan'
|
|
784
|
+
when 2 then 'grid'
|
|
785
|
+
when 3 then 'list'
|
|
786
|
+
when 4 then 'auto'
|
|
787
|
+
else 'auto'
|
|
788
|
+
end
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
# Parse a display-as argument into an integer value.
|
|
792
|
+
#
|
|
793
|
+
# Accepts "stack"/"s" and "folder"/"f".
|
|
794
|
+
#
|
|
795
|
+
# @param value [String, nil] Raw user input.
|
|
796
|
+
# @return [Integer, nil] Parsed display-as value, or +nil+ if invalid or +nil+.
|
|
797
|
+
def self.parse_display_as(value)
|
|
798
|
+
return nil if value.nil?
|
|
799
|
+
|
|
800
|
+
key = value.downcase
|
|
801
|
+
DISPLAY_AS_VALUES[key]
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# Convert a numeric display-as value into a human-readable name.
|
|
805
|
+
#
|
|
806
|
+
# @param value [Integer] Numeric display-as value.
|
|
807
|
+
# @return [String] Either "stack" or "folder".
|
|
808
|
+
def self.display_as_name(value)
|
|
809
|
+
case value
|
|
810
|
+
when 0 then 'stack'
|
|
811
|
+
when 1 then 'folder'
|
|
812
|
+
else 'stack'
|
|
813
|
+
end
|
|
814
|
+
end
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
# frozen_string_literal: true
|
|
820
|
+
|
|
821
|
+
module DockEdit
|
|
822
|
+
# Main class for managing Dock state and high-level operations.
|
|
823
|
+
#
|
|
824
|
+
# A {Dock} instance wraps a parsed Dock plist and provides helpers for
|
|
825
|
+
# locating and mutating app and folder tiles.
|
|
826
|
+
class Dock
|
|
827
|
+
attr_reader :doc, :apps_array, :others_array
|
|
828
|
+
|
|
829
|
+
# Create a new Dock wrapper around the current Dock plist.
|
|
830
|
+
#
|
|
831
|
+
# The plist is loaded via {PlistReader.load_dock_plist} and the
|
|
832
|
+
# `persistent-apps` and `persistent-others` arrays are extracted.
|
|
833
|
+
#
|
|
834
|
+
# @raise [SystemExit] Exits with status 1 if the arrays cannot be found.
|
|
835
|
+
def initialize
|
|
836
|
+
@doc = PlistReader.load_dock_plist
|
|
837
|
+
@apps_array = PlistReader.get_persistent_apps(@doc)
|
|
838
|
+
@others_array = PlistReader.get_persistent_others(@doc)
|
|
839
|
+
|
|
840
|
+
unless @apps_array && @others_array
|
|
841
|
+
$stderr.puts "Error: Could not find dock arrays in plist"
|
|
842
|
+
exit 1
|
|
843
|
+
end
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
# Persist changes to the Dock plist and restart the Dock.
|
|
847
|
+
#
|
|
848
|
+
# @param messages [String, Array<String>] Message or messages to print.
|
|
849
|
+
# @return [void]
|
|
850
|
+
def save(messages)
|
|
851
|
+
PlistWriter.write_plist_and_restart(@doc, messages)
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
# Find an application tile by name or bundle identifier.
|
|
855
|
+
#
|
|
856
|
+
# @param query [String] Search term.
|
|
857
|
+
# @return [Array<(Integer, String)>] A pair of `[index, display_name]`
|
|
858
|
+
# from the apps array, or `[nil, nil]` if not found.
|
|
859
|
+
def find_app(query)
|
|
860
|
+
app_dicts = @apps_array.elements.to_a.select { |e| e.is_a?(REXML::Element) && e.name == 'dict' }
|
|
861
|
+
Matcher.find_app_index(app_dicts, query)
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
# Find a folder tile by name, bundle identifier, or path.
|
|
865
|
+
#
|
|
866
|
+
# @param query [String] Search term (name or path).
|
|
867
|
+
# @param check_url [Boolean] Whether to match against the folder URL path.
|
|
868
|
+
# @return [Array<(Integer, String)>] A pair of `[index, display_name]`
|
|
869
|
+
# from the folders array, or `[nil, nil]` if not found.
|
|
870
|
+
def find_folder(query, check_url: false)
|
|
871
|
+
folder_dicts = @others_array.elements.to_a.select { |e| e.is_a?(REXML::Element) && e.name == 'dict' }
|
|
872
|
+
Matcher.find_item_index(folder_dicts, query, check_url: check_url)
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
# Find a tile in either the apps or folders section.
|
|
876
|
+
#
|
|
877
|
+
# Apps are searched first; if no match is found, folders are checked.
|
|
878
|
+
#
|
|
879
|
+
# @param query [String] Search term.
|
|
880
|
+
# @param check_url [Boolean] Whether to match against folder URL paths.
|
|
881
|
+
# @return [Array] A triple of `[array, element, display_name]`, or
|
|
882
|
+
# `[nil, nil, nil]` if nothing matches.
|
|
883
|
+
def find_item(query, check_url: false)
|
|
884
|
+
# Try apps first
|
|
885
|
+
index, name = find_app(query)
|
|
886
|
+
if index
|
|
887
|
+
app_dicts = @apps_array.elements.to_a.select { |e| e.is_a?(REXML::Element) && e.name == 'dict' }
|
|
888
|
+
return [@apps_array, app_dicts[index], name]
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
# Try folders
|
|
892
|
+
index, name = find_folder(query, check_url: check_url)
|
|
893
|
+
if index
|
|
894
|
+
folder_dicts = @others_array.elements.to_a.select { |e| e.is_a?(REXML::Element) && e.name == 'dict' }
|
|
895
|
+
return [@others_array, folder_dicts[index], name]
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
[nil, nil, nil]
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
# Return all app tile `<dict>` elements.
|
|
902
|
+
#
|
|
903
|
+
# @return [Array<REXML::Element>]
|
|
904
|
+
def get_app_dicts
|
|
905
|
+
@apps_array.elements.to_a.select { |e| e.is_a?(REXML::Element) && e.name == 'dict' }
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
# Return all folder tile `<dict>` elements.
|
|
909
|
+
#
|
|
910
|
+
# @return [Array<REXML::Element>]
|
|
911
|
+
def get_folder_dicts
|
|
912
|
+
@others_array.elements.to_a.select { |e| e.is_a?(REXML::Element) && e.name == 'dict' }
|
|
913
|
+
end
|
|
914
|
+
end
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
# frozen_string_literal: true
|
|
919
|
+
|
|
920
|
+
module DockEdit
|
|
921
|
+
# Command handlers for `dockedit` subcommands.
|
|
922
|
+
#
|
|
923
|
+
# Each method in this module implements the behavior for a single
|
|
924
|
+
# subcommand, operating on the Dock plist via {DockEdit::Dock}.
|
|
925
|
+
module Commands
|
|
926
|
+
# Implementation of the `space` subcommand.
|
|
927
|
+
#
|
|
928
|
+
# Inserts one or more spacer tiles in the apps section of the Dock.
|
|
929
|
+
#
|
|
930
|
+
# @param args [Array<String>] Command-line arguments for the subcommand.
|
|
931
|
+
# @return [void]
|
|
932
|
+
def self.space(args)
|
|
933
|
+
options = { small: false, after: [] }
|
|
934
|
+
|
|
935
|
+
parser = CLI.space_parser(options)
|
|
936
|
+
parser.order!(args)
|
|
937
|
+
|
|
938
|
+
small = options[:small]
|
|
939
|
+
after_apps = options[:after]
|
|
940
|
+
spacer_type = small ? 'Small space' : 'Space'
|
|
941
|
+
|
|
942
|
+
dock = Dock.new
|
|
943
|
+
messages = []
|
|
944
|
+
|
|
945
|
+
if after_apps.empty?
|
|
946
|
+
# Add single space at end
|
|
947
|
+
spacer = TileFactory.create_spacer_tile(small: small)
|
|
948
|
+
dock.apps_array.add(spacer)
|
|
949
|
+
messages << "#{spacer_type} added to end of Dock."
|
|
950
|
+
else
|
|
951
|
+
# Process each --after app
|
|
952
|
+
after_apps.each do |after_app|
|
|
953
|
+
# Re-fetch app_dicts each time since array changes after insert
|
|
954
|
+
app_dicts = dock.get_app_dicts
|
|
955
|
+
|
|
956
|
+
index, name = dock.find_app(after_app)
|
|
957
|
+
unless index
|
|
958
|
+
$stderr.puts "Error: App '#{after_app}' not found in Dock"
|
|
959
|
+
exit 1
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
spacer = TileFactory.create_spacer_tile(small: small)
|
|
963
|
+
dock.apps_array.insert_after(app_dicts[index], spacer)
|
|
964
|
+
messages << "#{spacer_type} inserted after '#{name}'."
|
|
965
|
+
end
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
dock.save(messages)
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
# Implementation of the `add` subcommand.
|
|
972
|
+
#
|
|
973
|
+
# Adds applications and/or folders to the Dock, optionally positioning
|
|
974
|
+
# them relative to an existing item and updating folder view/style.
|
|
975
|
+
#
|
|
976
|
+
# @param args [Array<String>] Command-line arguments for the subcommand.
|
|
977
|
+
# @return [void]
|
|
978
|
+
def self.add(args)
|
|
979
|
+
options = { after: nil, show_as: nil, display_as: nil }
|
|
980
|
+
|
|
981
|
+
parser = CLI.add_parser(options)
|
|
982
|
+
parser.permute!(args)
|
|
983
|
+
|
|
984
|
+
after_target = options[:after]
|
|
985
|
+
show_as = options[:show_as]
|
|
986
|
+
display_as = options[:display_as]
|
|
987
|
+
|
|
988
|
+
if args.empty?
|
|
989
|
+
$stderr.puts parser
|
|
990
|
+
exit 1
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
dock = Dock.new
|
|
994
|
+
messages = []
|
|
995
|
+
last_inserted_app_element = nil
|
|
996
|
+
last_inserted_folder_element = nil
|
|
997
|
+
|
|
998
|
+
# Find initial insertion point if --after specified
|
|
999
|
+
if after_target
|
|
1000
|
+
after_array, after_element, _after_name = dock.find_item(after_target, check_url: true)
|
|
1001
|
+
|
|
1002
|
+
if after_element
|
|
1003
|
+
if after_array == dock.apps_array
|
|
1004
|
+
last_inserted_app_element = after_element
|
|
1005
|
+
else
|
|
1006
|
+
last_inserted_folder_element = after_element
|
|
1007
|
+
end
|
|
1008
|
+
else
|
|
1009
|
+
$stderr.puts "Error: '#{after_target}' not found in Dock"
|
|
1010
|
+
exit 1
|
|
1011
|
+
end
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
# Process each argument
|
|
1015
|
+
args.each do |item_query|
|
|
1016
|
+
# Determine if this is a folder or app
|
|
1017
|
+
if PathUtils.folder_path?(item_query)
|
|
1018
|
+
# It's a folder
|
|
1019
|
+
folder_path = PathUtils.expand_path_shortcut(item_query)
|
|
1020
|
+
|
|
1021
|
+
unless File.directory?(folder_path)
|
|
1022
|
+
$stderr.puts "Error: Folder '#{folder_path}' not found"
|
|
1023
|
+
exit 1
|
|
1024
|
+
end
|
|
1025
|
+
|
|
1026
|
+
folder_name = File.basename(folder_path)
|
|
1027
|
+
|
|
1028
|
+
# Check if folder is already in dock
|
|
1029
|
+
existing_index, existing_name = dock.find_folder(folder_path, check_url: true)
|
|
1030
|
+
|
|
1031
|
+
if existing_index
|
|
1032
|
+
# If show_as or display_as was specified, update the existing folder
|
|
1033
|
+
updates = []
|
|
1034
|
+
folder_dicts = dock.get_folder_dicts
|
|
1035
|
+
if show_as
|
|
1036
|
+
FolderUpdater.update_folder_showas(folder_dicts[existing_index], show_as)
|
|
1037
|
+
updates << "view=#{Parsers.show_as_name(show_as)}"
|
|
1038
|
+
end
|
|
1039
|
+
if display_as
|
|
1040
|
+
FolderUpdater.update_folder_displayas(folder_dicts[existing_index], display_as)
|
|
1041
|
+
updates << "style=#{Parsers.display_as_name(display_as)}"
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
if updates.any?
|
|
1045
|
+
messages << "'#{existing_name}' updated (#{updates.join(', ')})."
|
|
1046
|
+
else
|
|
1047
|
+
$stderr.puts "Warning: '#{existing_name}' is already in the Dock, skipping"
|
|
1048
|
+
end
|
|
1049
|
+
next
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
# Create the folder tile (use defaults if not specified)
|
|
1053
|
+
folder_tile = TileFactory.create_folder_tile(folder_path, show_as: show_as || 4, display_as: display_as || 1)
|
|
1054
|
+
|
|
1055
|
+
if last_inserted_folder_element
|
|
1056
|
+
dock.others_array.insert_after(last_inserted_folder_element, folder_tile)
|
|
1057
|
+
else
|
|
1058
|
+
dock.others_array.add(folder_tile)
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
last_inserted_folder_element = folder_tile
|
|
1062
|
+
messages << "'#{folder_name}' folder added to Dock."
|
|
1063
|
+
|
|
1064
|
+
elsif PathUtils.explicit_app_path?(item_query)
|
|
1065
|
+
# Explicit app path given
|
|
1066
|
+
app_path = File.expand_path(item_query)
|
|
1067
|
+
|
|
1068
|
+
unless File.exist?(File.join(app_path, 'Contents', 'Info.plist'))
|
|
1069
|
+
$stderr.puts "Error: Application path '#{app_path}' not found"
|
|
1070
|
+
exit 1
|
|
1071
|
+
end
|
|
1072
|
+
|
|
1073
|
+
app_info = PlistReader.read_app_info(app_path)
|
|
1074
|
+
unless app_info && app_info['CFBundleIdentifier']
|
|
1075
|
+
$stderr.puts "Error: Could not read app info from '#{app_path}'"
|
|
1076
|
+
exit 1
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
app_name = app_info['CFBundleName'] || app_info['CFBundleDisplayName'] || File.basename(app_path, '.app')
|
|
1080
|
+
|
|
1081
|
+
# Check if app is already in dock
|
|
1082
|
+
existing_index, existing_name = dock.find_app(app_info['CFBundleIdentifier'])
|
|
1083
|
+
|
|
1084
|
+
if existing_index
|
|
1085
|
+
$stderr.puts "Warning: '#{existing_name}' is already in the Dock, skipping"
|
|
1086
|
+
next
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
# Create the app tile
|
|
1090
|
+
app_tile = TileFactory.create_app_tile(app_path, app_info)
|
|
1091
|
+
|
|
1092
|
+
if last_inserted_app_element
|
|
1093
|
+
dock.apps_array.insert_after(last_inserted_app_element, app_tile)
|
|
1094
|
+
last_inserted_app_element = app_tile
|
|
1095
|
+
else
|
|
1096
|
+
dock.apps_array.add(app_tile)
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1099
|
+
messages << "'#{app_name}' added to Dock."
|
|
1100
|
+
|
|
1101
|
+
else
|
|
1102
|
+
# Search for app by name
|
|
1103
|
+
app_path = AppFinder.find_app_on_disk(item_query)
|
|
1104
|
+
unless app_path
|
|
1105
|
+
$stderr.puts "Error: App '#{item_query}' not found"
|
|
1106
|
+
exit 1
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
app_info = PlistReader.read_app_info(app_path)
|
|
1110
|
+
unless app_info && app_info['CFBundleIdentifier']
|
|
1111
|
+
$stderr.puts "Error: Could not read app info from '#{app_path}'"
|
|
1112
|
+
exit 1
|
|
1113
|
+
end
|
|
1114
|
+
|
|
1115
|
+
app_name = app_info['CFBundleName'] || app_info['CFBundleDisplayName'] || File.basename(app_path, '.app')
|
|
1116
|
+
|
|
1117
|
+
# Check if app is already in dock
|
|
1118
|
+
existing_index, existing_name = dock.find_app(app_info['CFBundleIdentifier'])
|
|
1119
|
+
|
|
1120
|
+
if existing_index
|
|
1121
|
+
$stderr.puts "Warning: '#{existing_name}' is already in the Dock, skipping"
|
|
1122
|
+
next
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
# Create the app tile
|
|
1126
|
+
app_tile = TileFactory.create_app_tile(app_path, app_info)
|
|
1127
|
+
|
|
1128
|
+
if last_inserted_app_element
|
|
1129
|
+
dock.apps_array.insert_after(last_inserted_app_element, app_tile)
|
|
1130
|
+
last_inserted_app_element = app_tile
|
|
1131
|
+
else
|
|
1132
|
+
dock.apps_array.add(app_tile)
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
messages << "'#{app_name}' added to Dock."
|
|
1136
|
+
end
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
if messages.empty?
|
|
1140
|
+
$stderr.puts "No items were added to the Dock"
|
|
1141
|
+
exit 1
|
|
1142
|
+
end
|
|
1143
|
+
|
|
1144
|
+
dock.save(messages)
|
|
1145
|
+
end
|
|
1146
|
+
|
|
1147
|
+
# Implementation of the `remove` subcommand.
|
|
1148
|
+
#
|
|
1149
|
+
# Removes apps and/or folders from the Dock by name, bundle identifier,
|
|
1150
|
+
# or path.
|
|
1151
|
+
#
|
|
1152
|
+
# @param args [Array<String>] Command-line arguments for the subcommand.
|
|
1153
|
+
# @return [void]
|
|
1154
|
+
def self.remove(args)
|
|
1155
|
+
parser = CLI.remove_parser({})
|
|
1156
|
+
parser.order!(args)
|
|
1157
|
+
|
|
1158
|
+
if args.empty?
|
|
1159
|
+
$stderr.puts parser
|
|
1160
|
+
exit 1
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
dock = Dock.new
|
|
1164
|
+
messages = []
|
|
1165
|
+
|
|
1166
|
+
# Process each argument
|
|
1167
|
+
args.each do |item_query|
|
|
1168
|
+
found = false
|
|
1169
|
+
|
|
1170
|
+
# Determine if this looks like a path
|
|
1171
|
+
is_path = item_query.include?('/')
|
|
1172
|
+
|
|
1173
|
+
# First check persistent-apps
|
|
1174
|
+
index, name = dock.find_app(item_query)
|
|
1175
|
+
|
|
1176
|
+
if index
|
|
1177
|
+
app_dicts = dock.get_app_dicts
|
|
1178
|
+
dock.apps_array.delete(app_dicts[index])
|
|
1179
|
+
messages << "'#{name}' removed from Dock."
|
|
1180
|
+
found = true
|
|
1181
|
+
end
|
|
1182
|
+
|
|
1183
|
+
# If not found in apps, check persistent-others (folders)
|
|
1184
|
+
unless found
|
|
1185
|
+
index, name = dock.find_folder(item_query, check_url: is_path)
|
|
1186
|
+
|
|
1187
|
+
if index
|
|
1188
|
+
folder_dicts = dock.get_folder_dicts
|
|
1189
|
+
dock.others_array.delete(folder_dicts[index])
|
|
1190
|
+
messages << "'#{name}' removed from Dock."
|
|
1191
|
+
found = true
|
|
1192
|
+
end
|
|
1193
|
+
end
|
|
1194
|
+
|
|
1195
|
+
unless found
|
|
1196
|
+
$stderr.puts "Warning: '#{item_query}' not found in Dock, skipping"
|
|
1197
|
+
end
|
|
1198
|
+
end
|
|
1199
|
+
|
|
1200
|
+
if messages.empty?
|
|
1201
|
+
$stderr.puts "No items were removed from the Dock"
|
|
1202
|
+
exit 1
|
|
1203
|
+
end
|
|
1204
|
+
|
|
1205
|
+
dock.save(messages)
|
|
1206
|
+
end
|
|
1207
|
+
|
|
1208
|
+
# Implementation of the `move` subcommand.
|
|
1209
|
+
#
|
|
1210
|
+
# Moves an existing Dock item after another item in the same section.
|
|
1211
|
+
#
|
|
1212
|
+
# @param args [Array<String>] Command-line arguments for the subcommand.
|
|
1213
|
+
# @return [void]
|
|
1214
|
+
def self.move(args)
|
|
1215
|
+
# Accept either order: move --after TARGET ITEM or move ITEM --after TARGET
|
|
1216
|
+
options = { after: nil }
|
|
1217
|
+
parser = CLI.move_parser(options)
|
|
1218
|
+
parser.permute!(args)
|
|
1219
|
+
|
|
1220
|
+
# Now, args should contain the non-option arguments (either [item] or [target, item] or [item, target])
|
|
1221
|
+
after_target = options[:after]
|
|
1222
|
+
|
|
1223
|
+
# Accept either order: --after TARGET ITEM or ITEM --after TARGET
|
|
1224
|
+
item_query = nil
|
|
1225
|
+
if after_target && args.length == 1
|
|
1226
|
+
item_query = args.first
|
|
1227
|
+
elsif after_target && args.length == 2
|
|
1228
|
+
# Try to infer which is the item and which is the target
|
|
1229
|
+
if args[0].downcase == after_target.downcase
|
|
1230
|
+
item_query = args[1]
|
|
1231
|
+
elsif args[1].downcase == after_target.downcase
|
|
1232
|
+
item_query = args[0]
|
|
1233
|
+
else
|
|
1234
|
+
# Default: treat first as item
|
|
1235
|
+
item_query = args[0]
|
|
1236
|
+
end
|
|
1237
|
+
else
|
|
1238
|
+
$stderr.puts parser
|
|
1239
|
+
$stderr.puts "\nError: You must specify an item to move and a target with --after."
|
|
1240
|
+
exit 1
|
|
1241
|
+
end
|
|
1242
|
+
|
|
1243
|
+
if !after_target || !item_query
|
|
1244
|
+
$stderr.puts parser
|
|
1245
|
+
$stderr.puts "\nError: You must specify an item to move and a target with --after."
|
|
1246
|
+
exit 1
|
|
1247
|
+
end
|
|
1248
|
+
|
|
1249
|
+
dock = Dock.new
|
|
1250
|
+
|
|
1251
|
+
# Find the item to move (check apps first, then folders)
|
|
1252
|
+
move_array, move_element, move_name = dock.find_item(item_query, check_url: true)
|
|
1253
|
+
|
|
1254
|
+
unless move_element
|
|
1255
|
+
$stderr.puts "Error: '#{item_query}' not found in Dock"
|
|
1256
|
+
exit 1
|
|
1257
|
+
end
|
|
1258
|
+
|
|
1259
|
+
# Find the target (check apps first, then folders)
|
|
1260
|
+
after_array, after_element, after_name = dock.find_item(after_target, check_url: true)
|
|
1261
|
+
|
|
1262
|
+
unless after_element
|
|
1263
|
+
$stderr.puts "Error: '#{after_target}' not found in Dock"
|
|
1264
|
+
exit 1
|
|
1265
|
+
end
|
|
1266
|
+
|
|
1267
|
+
# Check if they're the same item
|
|
1268
|
+
if move_element == after_element
|
|
1269
|
+
$stderr.puts "Error: Cannot move an item after itself"
|
|
1270
|
+
exit 1
|
|
1271
|
+
end
|
|
1272
|
+
|
|
1273
|
+
# Check if moving between arrays (apps <-> folders) - not allowed
|
|
1274
|
+
if move_array != after_array
|
|
1275
|
+
$stderr.puts "Error: Cannot move items between apps and folders sections"
|
|
1276
|
+
exit 1
|
|
1277
|
+
end
|
|
1278
|
+
|
|
1279
|
+
# Remove from current position
|
|
1280
|
+
move_array.delete(move_element)
|
|
1281
|
+
|
|
1282
|
+
# Insert after target
|
|
1283
|
+
move_array.insert_after(after_element, move_element)
|
|
1284
|
+
|
|
1285
|
+
dock.save("'#{move_name}' moved after '#{after_name}'.")
|
|
1286
|
+
end
|
|
1287
|
+
end
|
|
1288
|
+
end
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
# frozen_string_literal: true
|
|
1292
|
+
|
|
1293
|
+
module DockEdit
|
|
1294
|
+
# Command-line interface and option parsing for the `dockedit` executable.
|
|
1295
|
+
#
|
|
1296
|
+
# This class is responsible for parsing global arguments and subcommands and
|
|
1297
|
+
# delegating to the appropriate handlers in {DockEdit::Commands}.
|
|
1298
|
+
class CLI
|
|
1299
|
+
# Build the option parser for the `add` subcommand.
|
|
1300
|
+
#
|
|
1301
|
+
# @param options [Hash] Mutable options hash to be populated by OptionParser.
|
|
1302
|
+
# @return [OptionParser] Configured parser instance.
|
|
1303
|
+
def self.add_parser(options)
|
|
1304
|
+
OptionParser.new do |opts|
|
|
1305
|
+
opts.banner = "Usage: dockedit add [options] <app_or_folder> [...]"
|
|
1306
|
+
opts.separator ""
|
|
1307
|
+
opts.separator "Examples:"
|
|
1308
|
+
opts.separator " dockedit add Safari Terminal"
|
|
1309
|
+
opts.separator " dockedit add ~/Downloads --show grid --display stack"
|
|
1310
|
+
opts.separator " dockedit add --after Safari Notes"
|
|
1311
|
+
opts.separator " dockedit add ~/Sites --display folder --show grid"
|
|
1312
|
+
opts.on('-a', '--after ITEM', 'Insert after specified app/folder (fuzzy match)') { |app| options[:after] = app }
|
|
1313
|
+
opts.on('--show TYPE', '--view TYPE', 'Folder view: fan/f, grid/g, list/l, auto/a (default: auto)') { |t| options[:show_as] = Parsers.parse_show_as(t) }
|
|
1314
|
+
opts.on('--display TYPE', 'Folder style: folder/f, stack/s (default: folder)') { |t| options[:display_as] = Parsers.parse_display_as(t) }
|
|
1315
|
+
end
|
|
1316
|
+
end
|
|
1317
|
+
|
|
1318
|
+
# Build the option parser for the `space` subcommand.
|
|
1319
|
+
#
|
|
1320
|
+
# @param options [Hash] Mutable options hash to be populated by OptionParser.
|
|
1321
|
+
# @return [OptionParser] Configured parser instance.
|
|
1322
|
+
def self.space_parser(options)
|
|
1323
|
+
OptionParser.new do |opts|
|
|
1324
|
+
opts.banner = "Usage: dockedit space [options]"
|
|
1325
|
+
opts.separator ""
|
|
1326
|
+
opts.separator "Examples:"
|
|
1327
|
+
opts.separator " dockedit space"
|
|
1328
|
+
opts.separator " dockedit space --small"
|
|
1329
|
+
opts.separator " dockedit space --after Safari"
|
|
1330
|
+
opts.separator " dockedit space --small --after Terminal --after Safari"
|
|
1331
|
+
opts.on('-s', '--small', '--half', 'Insert a small/half-size space') { options[:small] = true }
|
|
1332
|
+
opts.on('-a', '--after APP', 'Insert after specified app (fuzzy match, repeatable)') { |app| options[:after] << app }
|
|
1333
|
+
end
|
|
1334
|
+
end
|
|
1335
|
+
|
|
1336
|
+
# Build the option parser for the `move` subcommand.
|
|
1337
|
+
#
|
|
1338
|
+
# @param options [Hash] Mutable options hash to be populated by OptionParser.
|
|
1339
|
+
# @return [OptionParser] Configured parser instance.
|
|
1340
|
+
def self.move_parser(options)
|
|
1341
|
+
OptionParser.new do |opts|
|
|
1342
|
+
opts.banner = "Usage: dockedit move --after <target> <item_to_move> OR dockedit move <item_to_move> --after <target>"
|
|
1343
|
+
opts.separator ""
|
|
1344
|
+
opts.separator "Examples:"
|
|
1345
|
+
opts.separator " dockedit move --after Terminal Safari"
|
|
1346
|
+
opts.separator " dockedit move Safari --after Terminal"
|
|
1347
|
+
opts.on('-a', '--after ITEM', 'Move after specified app/folder (required, fuzzy match)') { |app| options[:after] = app }
|
|
1348
|
+
end
|
|
1349
|
+
end
|
|
1350
|
+
|
|
1351
|
+
# Build the option parser for the `remove` subcommand.
|
|
1352
|
+
#
|
|
1353
|
+
# @param _options [Hash] Present for API symmetry; currently unused.
|
|
1354
|
+
# @return [OptionParser] Configured parser instance.
|
|
1355
|
+
def self.remove_parser(_options = {})
|
|
1356
|
+
OptionParser.new do |opts|
|
|
1357
|
+
opts.banner = "Usage: dockedit remove <app_or_folder> [...]"
|
|
1358
|
+
opts.separator ""
|
|
1359
|
+
opts.separator "Examples:"
|
|
1360
|
+
opts.separator " dockedit remove Safari Terminal"
|
|
1361
|
+
opts.separator " dockedit remove ~/Downloads"
|
|
1362
|
+
opts.separator " dockedit remove --help"
|
|
1363
|
+
end
|
|
1364
|
+
end
|
|
1365
|
+
|
|
1366
|
+
# Build the top-level option parser for the `dockedit` command.
|
|
1367
|
+
#
|
|
1368
|
+
# This parser prints a summary of all subcommands and global options.
|
|
1369
|
+
#
|
|
1370
|
+
# @return [OptionParser]
|
|
1371
|
+
def self.main_parser
|
|
1372
|
+
OptionParser.new do |opts|
|
|
1373
|
+
opts.banner = "Usage: dockedit <subcommand> [options] [args]"
|
|
1374
|
+
opts.separator ""
|
|
1375
|
+
opts.separator "Subcommands:"
|
|
1376
|
+
opts.separator " add [-a|--after <item>] [--show-as TYPE] <item>... Add app(s)/folder(s)"
|
|
1377
|
+
opts.separator " move -a|--after <item> <item> Move an item after another"
|
|
1378
|
+
opts.separator " remove <item>... Remove app(s)/folder(s)"
|
|
1379
|
+
opts.separator " space [-s|--small] [-a|--after <app>] Insert space(s)"
|
|
1380
|
+
opts.separator " help [subcommand] Show help for a subcommand"
|
|
1381
|
+
opts.separator ""
|
|
1382
|
+
opts.separator "Folder shortcuts: desktop, downloads, home, library, documents, applications, sites"
|
|
1383
|
+
opts.separator ""
|
|
1384
|
+
opts.separator "Examples:"
|
|
1385
|
+
opts.separator " dockedit add Safari Terminal"
|
|
1386
|
+
opts.separator " dockedit add ~/Downloads --show grid --display stack"
|
|
1387
|
+
opts.separator " dockedit add Notes --after Safari"
|
|
1388
|
+
opts.separator " dockedit space --small --after Safari"
|
|
1389
|
+
opts.separator " dockedit move --after Terminal Safari"
|
|
1390
|
+
opts.separator " dockedit move Safari --after Terminal"
|
|
1391
|
+
opts.separator " dockedit help add"
|
|
1392
|
+
opts.separator ""
|
|
1393
|
+
opts.separator "Options:"
|
|
1394
|
+
opts.on('-h', '--help', 'Show this help') do
|
|
1395
|
+
puts opts
|
|
1396
|
+
exit 0
|
|
1397
|
+
end
|
|
1398
|
+
end
|
|
1399
|
+
end
|
|
1400
|
+
|
|
1401
|
+
# Main entry point for the `dockedit` CLI.
|
|
1402
|
+
#
|
|
1403
|
+
# Reads +ARGV+, dispatches to subcommands, and exits with an appropriate
|
|
1404
|
+
# status code.
|
|
1405
|
+
#
|
|
1406
|
+
# @return [void]
|
|
1407
|
+
def self.run
|
|
1408
|
+
global_parser = main_parser
|
|
1409
|
+
|
|
1410
|
+
if ARGV.empty?
|
|
1411
|
+
puts global_parser
|
|
1412
|
+
exit 0
|
|
1413
|
+
end
|
|
1414
|
+
|
|
1415
|
+
subcommand = ARGV.shift
|
|
1416
|
+
|
|
1417
|
+
case subcommand
|
|
1418
|
+
when '-v', '--version'
|
|
1419
|
+
puts DockEdit::VERSION
|
|
1420
|
+
exit 0
|
|
1421
|
+
when 'add'
|
|
1422
|
+
Commands.add(ARGV)
|
|
1423
|
+
when 'move'
|
|
1424
|
+
Commands.move(ARGV)
|
|
1425
|
+
when 'remove'
|
|
1426
|
+
Commands.remove(ARGV)
|
|
1427
|
+
when 'space'
|
|
1428
|
+
Commands.space(ARGV)
|
|
1429
|
+
when 'help', '-h', '--help'
|
|
1430
|
+
help_target = ARGV.shift
|
|
1431
|
+
case help_target
|
|
1432
|
+
when nil
|
|
1433
|
+
puts main_parser
|
|
1434
|
+
when 'add'
|
|
1435
|
+
puts add_parser({})
|
|
1436
|
+
when 'move'
|
|
1437
|
+
puts move_parser({})
|
|
1438
|
+
when 'remove'
|
|
1439
|
+
puts remove_parser({})
|
|
1440
|
+
when 'space'
|
|
1441
|
+
puts space_parser({})
|
|
1442
|
+
else
|
|
1443
|
+
$stderr.puts "Unknown subcommand for help: #{help_target}"
|
|
1444
|
+
$stderr.puts "Valid subcommands: add, move, remove, space"
|
|
1445
|
+
exit 1
|
|
1446
|
+
end
|
|
1447
|
+
exit 0
|
|
1448
|
+
else
|
|
1449
|
+
$stderr.puts "Unknown subcommand: #{subcommand}"
|
|
1450
|
+
$stderr.puts "Run 'dockedit --help' for usage"
|
|
1451
|
+
exit 1
|
|
1452
|
+
end
|
|
1453
|
+
end
|
|
1454
|
+
end
|
|
1455
|
+
end
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
if __FILE__ == $0
|
|
1460
|
+
DockEdit::CLI.run
|
|
1461
|
+
end
|