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.
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockEdit
4
+ # Handles finding applications on disk using Spotlight (mdfind).
5
+ #
6
+ # This class is used by commands that accept an application name (e.g. "Safari")
7
+ # and need to resolve it to a full `.app` bundle path.
8
+ class AppFinder
9
+ # Find an application bundle on disk using Spotlight.
10
+ #
11
+ # The search is limited to `/Applications` and `/System/Applications`.
12
+ # Results are scored using {DockEdit::Matcher.match_score} and the best
13
+ # matching `.app` path is returned.
14
+ #
15
+ # @param query [String] Human-friendly app name to search for.
16
+ # @return [String, nil] Absolute path to the best matching `.app`, or +nil+
17
+ # if no match is found.
18
+ def self.find_app_on_disk(query)
19
+ # Search in /Applications and /System/Applications
20
+ result = `mdfind -onlyin /Applications -onlyin /System/Applications 'kMDItemKind == "Application" && kMDItemDisplayName == "*#{query}*"cd' 2>/dev/null`.strip
21
+
22
+ if result.empty?
23
+ # Fallback to filename search
24
+ result = `mdfind -onlyin /Applications -onlyin /System/Applications 'kMDItemFSName == "*#{query}*.app"cd' 2>/dev/null`.strip
25
+ end
26
+
27
+ return nil if result.empty?
28
+
29
+ apps = result.split("\n").select { |p| p.end_with?('.app') }
30
+ return nil if apps.empty?
31
+
32
+ # Score and sort by best match (shortest name wins on equal score)
33
+ scored = apps.map do |path|
34
+ name = File.basename(path, '.app')
35
+ score = Matcher.match_score(query, name)
36
+ [path, name, score || 999, name.length]
37
+ end
38
+
39
+ # Sort by score, then by name length
40
+ scored.sort_by! { |_, _, score, len| [score, len] }
41
+
42
+ scored.first&.first
43
+ end
44
+ end
45
+ end
46
+
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockEdit
4
+ # Command-line interface and option parsing for the `dockedit` executable.
5
+ #
6
+ # This class is responsible for parsing global arguments and subcommands and
7
+ # delegating to the appropriate handlers in {DockEdit::Commands}.
8
+ class CLI
9
+ # Build the option parser for the `add` subcommand.
10
+ #
11
+ # @param options [Hash] Mutable options hash to be populated by OptionParser.
12
+ # @return [OptionParser] Configured parser instance.
13
+ def self.add_parser(options)
14
+ OptionParser.new do |opts|
15
+ opts.banner = "Usage: dockedit add [options] <app_or_folder> [...]"
16
+ opts.separator ""
17
+ opts.separator "Examples:"
18
+ opts.separator " dockedit add Safari Terminal"
19
+ opts.separator " dockedit add ~/Downloads --show grid --display stack"
20
+ opts.separator " dockedit add --after Safari Notes"
21
+ opts.separator " dockedit add ~/Sites --display folder --show grid"
22
+ opts.on('-a', '--after ITEM', 'Insert after specified app/folder (fuzzy match)') { |app| options[:after] = app }
23
+ 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) }
24
+ opts.on('--display TYPE', 'Folder style: folder/f, stack/s (default: folder)') { |t| options[:display_as] = Parsers.parse_display_as(t) }
25
+ end
26
+ end
27
+
28
+ # Build the option parser for the `space` subcommand.
29
+ #
30
+ # @param options [Hash] Mutable options hash to be populated by OptionParser.
31
+ # @return [OptionParser] Configured parser instance.
32
+ def self.space_parser(options)
33
+ OptionParser.new do |opts|
34
+ opts.banner = "Usage: dockedit space [options]"
35
+ opts.separator ""
36
+ opts.separator "Examples:"
37
+ opts.separator " dockedit space"
38
+ opts.separator " dockedit space --small"
39
+ opts.separator " dockedit space --after Safari"
40
+ opts.separator " dockedit space --small --after Terminal --after Safari"
41
+ opts.on('-s', '--small', '--half', 'Insert a small/half-size space') { options[:small] = true }
42
+ opts.on('-a', '--after APP', 'Insert after specified app (fuzzy match, repeatable)') { |app| options[:after] << app }
43
+ end
44
+ end
45
+
46
+ # Build the option parser for the `move` subcommand.
47
+ #
48
+ # @param options [Hash] Mutable options hash to be populated by OptionParser.
49
+ # @return [OptionParser] Configured parser instance.
50
+ def self.move_parser(options)
51
+ OptionParser.new do |opts|
52
+ opts.banner = "Usage: dockedit move --after <target> <item_to_move> OR dockedit move <item_to_move> --after <target>"
53
+ opts.separator ""
54
+ opts.separator "Examples:"
55
+ opts.separator " dockedit move --after Terminal Safari"
56
+ opts.separator " dockedit move Safari --after Terminal"
57
+ opts.on('-a', '--after ITEM', 'Move after specified app/folder (required, fuzzy match)') { |app| options[:after] = app }
58
+ end
59
+ end
60
+
61
+ # Build the option parser for the `remove` subcommand.
62
+ #
63
+ # @param _options [Hash] Present for API symmetry; currently unused.
64
+ # @return [OptionParser] Configured parser instance.
65
+ def self.remove_parser(_options = {})
66
+ OptionParser.new do |opts|
67
+ opts.banner = "Usage: dockedit remove <app_or_folder> [...]"
68
+ opts.separator ""
69
+ opts.separator "Examples:"
70
+ opts.separator " dockedit remove Safari Terminal"
71
+ opts.separator " dockedit remove ~/Downloads"
72
+ opts.separator " dockedit remove --help"
73
+ end
74
+ end
75
+
76
+ # Build the top-level option parser for the `dockedit` command.
77
+ #
78
+ # This parser prints a summary of all subcommands and global options.
79
+ #
80
+ # @return [OptionParser]
81
+ def self.main_parser
82
+ OptionParser.new do |opts|
83
+ opts.banner = "Usage: dockedit <subcommand> [options] [args]"
84
+ opts.separator ""
85
+ opts.separator "Subcommands:"
86
+ opts.separator " add [-a|--after <item>] [--show-as TYPE] <item>... Add app(s)/folder(s)"
87
+ opts.separator " move -a|--after <item> <item> Move an item after another"
88
+ opts.separator " remove <item>... Remove app(s)/folder(s)"
89
+ opts.separator " space [-s|--small] [-a|--after <app>] Insert space(s)"
90
+ opts.separator " help [subcommand] Show help for a subcommand"
91
+ opts.separator ""
92
+ opts.separator "Folder shortcuts: desktop, downloads, home, library, documents, applications, sites"
93
+ opts.separator ""
94
+ opts.separator "Examples:"
95
+ opts.separator " dockedit add Safari Terminal"
96
+ opts.separator " dockedit add ~/Downloads --show grid --display stack"
97
+ opts.separator " dockedit add Notes --after Safari"
98
+ opts.separator " dockedit space --small --after Safari"
99
+ opts.separator " dockedit move --after Terminal Safari"
100
+ opts.separator " dockedit move Safari --after Terminal"
101
+ opts.separator " dockedit help add"
102
+ opts.separator ""
103
+ opts.separator "Options:"
104
+ opts.on('-h', '--help', 'Show this help') do
105
+ puts opts
106
+ exit 0
107
+ end
108
+ end
109
+ end
110
+
111
+ # Main entry point for the `dockedit` CLI.
112
+ #
113
+ # Reads +ARGV+, dispatches to subcommands, and exits with an appropriate
114
+ # status code.
115
+ #
116
+ # @return [void]
117
+ def self.run
118
+ global_parser = main_parser
119
+
120
+ if ARGV.empty?
121
+ puts global_parser
122
+ exit 0
123
+ end
124
+
125
+ subcommand = ARGV.shift
126
+
127
+ case subcommand
128
+ when '-v', '--version'
129
+ puts DockEdit::VERSION
130
+ exit 0
131
+ when 'add'
132
+ Commands.add(ARGV)
133
+ when 'move'
134
+ Commands.move(ARGV)
135
+ when 'remove'
136
+ Commands.remove(ARGV)
137
+ when 'space'
138
+ Commands.space(ARGV)
139
+ when 'help', '-h', '--help'
140
+ help_target = ARGV.shift
141
+ case help_target
142
+ when nil
143
+ puts main_parser
144
+ when 'add'
145
+ puts add_parser({})
146
+ when 'move'
147
+ puts move_parser({})
148
+ when 'remove'
149
+ puts remove_parser({})
150
+ when 'space'
151
+ puts space_parser({})
152
+ else
153
+ $stderr.puts "Unknown subcommand for help: #{help_target}"
154
+ $stderr.puts "Valid subcommands: add, move, remove, space"
155
+ exit 1
156
+ end
157
+ exit 0
158
+ else
159
+ $stderr.puts "Unknown subcommand: #{subcommand}"
160
+ $stderr.puts "Run 'dockedit --help' for usage"
161
+ exit 1
162
+ end
163
+ end
164
+ end
165
+ end
166
+
@@ -0,0 +1,372 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockEdit
4
+ # Command handlers for `dockedit` subcommands.
5
+ #
6
+ # Each method in this module implements the behavior for a single
7
+ # subcommand, operating on the Dock plist via {DockEdit::Dock}.
8
+ module Commands
9
+ # Implementation of the `space` subcommand.
10
+ #
11
+ # Inserts one or more spacer tiles in the apps section of the Dock.
12
+ #
13
+ # @param args [Array<String>] Command-line arguments for the subcommand.
14
+ # @return [void]
15
+ def self.space(args)
16
+ options = { small: false, after: [] }
17
+
18
+ parser = CLI.space_parser(options)
19
+ parser.order!(args)
20
+
21
+ small = options[:small]
22
+ after_apps = options[:after]
23
+ spacer_type = small ? 'Small space' : 'Space'
24
+
25
+ dock = Dock.new
26
+ messages = []
27
+
28
+ if after_apps.empty?
29
+ # Add single space at end
30
+ spacer = TileFactory.create_spacer_tile(small: small)
31
+ dock.apps_array.add(spacer)
32
+ messages << "#{spacer_type} added to end of Dock."
33
+ else
34
+ # Process each --after app
35
+ after_apps.each do |after_app|
36
+ # Re-fetch app_dicts each time since array changes after insert
37
+ app_dicts = dock.get_app_dicts
38
+
39
+ index, name = dock.find_app(after_app)
40
+ unless index
41
+ $stderr.puts "Error: App '#{after_app}' not found in Dock"
42
+ exit 1
43
+ end
44
+
45
+ spacer = TileFactory.create_spacer_tile(small: small)
46
+ dock.apps_array.insert_after(app_dicts[index], spacer)
47
+ messages << "#{spacer_type} inserted after '#{name}'."
48
+ end
49
+ end
50
+
51
+ dock.save(messages)
52
+ end
53
+
54
+ # Implementation of the `add` subcommand.
55
+ #
56
+ # Adds applications and/or folders to the Dock, optionally positioning
57
+ # them relative to an existing item and updating folder view/style.
58
+ #
59
+ # @param args [Array<String>] Command-line arguments for the subcommand.
60
+ # @return [void]
61
+ def self.add(args)
62
+ options = { after: nil, show_as: nil, display_as: nil }
63
+
64
+ parser = CLI.add_parser(options)
65
+ parser.permute!(args)
66
+
67
+ after_target = options[:after]
68
+ show_as = options[:show_as]
69
+ display_as = options[:display_as]
70
+
71
+ if args.empty?
72
+ $stderr.puts parser
73
+ exit 1
74
+ end
75
+
76
+ dock = Dock.new
77
+ messages = []
78
+ last_inserted_app_element = nil
79
+ last_inserted_folder_element = nil
80
+
81
+ # Find initial insertion point if --after specified
82
+ if after_target
83
+ after_array, after_element, _after_name = dock.find_item(after_target, check_url: true)
84
+
85
+ if after_element
86
+ if after_array == dock.apps_array
87
+ last_inserted_app_element = after_element
88
+ else
89
+ last_inserted_folder_element = after_element
90
+ end
91
+ else
92
+ $stderr.puts "Error: '#{after_target}' not found in Dock"
93
+ exit 1
94
+ end
95
+ end
96
+
97
+ # Process each argument
98
+ args.each do |item_query|
99
+ # Determine if this is a folder or app
100
+ if PathUtils.folder_path?(item_query)
101
+ # It's a folder
102
+ folder_path = PathUtils.expand_path_shortcut(item_query)
103
+
104
+ unless File.directory?(folder_path)
105
+ $stderr.puts "Error: Folder '#{folder_path}' not found"
106
+ exit 1
107
+ end
108
+
109
+ folder_name = File.basename(folder_path)
110
+
111
+ # Check if folder is already in dock
112
+ existing_index, existing_name = dock.find_folder(folder_path, check_url: true)
113
+
114
+ if existing_index
115
+ # If show_as or display_as was specified, update the existing folder
116
+ updates = []
117
+ folder_dicts = dock.get_folder_dicts
118
+ if show_as
119
+ FolderUpdater.update_folder_showas(folder_dicts[existing_index], show_as)
120
+ updates << "view=#{Parsers.show_as_name(show_as)}"
121
+ end
122
+ if display_as
123
+ FolderUpdater.update_folder_displayas(folder_dicts[existing_index], display_as)
124
+ updates << "style=#{Parsers.display_as_name(display_as)}"
125
+ end
126
+
127
+ if updates.any?
128
+ messages << "'#{existing_name}' updated (#{updates.join(', ')})."
129
+ else
130
+ $stderr.puts "Warning: '#{existing_name}' is already in the Dock, skipping"
131
+ end
132
+ next
133
+ end
134
+
135
+ # Create the folder tile (use defaults if not specified)
136
+ folder_tile = TileFactory.create_folder_tile(folder_path, show_as: show_as || 4, display_as: display_as || 1)
137
+
138
+ if last_inserted_folder_element
139
+ dock.others_array.insert_after(last_inserted_folder_element, folder_tile)
140
+ else
141
+ dock.others_array.add(folder_tile)
142
+ end
143
+
144
+ last_inserted_folder_element = folder_tile
145
+ messages << "'#{folder_name}' folder added to Dock."
146
+
147
+ elsif PathUtils.explicit_app_path?(item_query)
148
+ # Explicit app path given
149
+ app_path = File.expand_path(item_query)
150
+
151
+ unless File.exist?(File.join(app_path, 'Contents', 'Info.plist'))
152
+ $stderr.puts "Error: Application path '#{app_path}' not found"
153
+ exit 1
154
+ end
155
+
156
+ app_info = PlistReader.read_app_info(app_path)
157
+ unless app_info && app_info['CFBundleIdentifier']
158
+ $stderr.puts "Error: Could not read app info from '#{app_path}'"
159
+ exit 1
160
+ end
161
+
162
+ app_name = app_info['CFBundleName'] || app_info['CFBundleDisplayName'] || File.basename(app_path, '.app')
163
+
164
+ # Check if app is already in dock
165
+ existing_index, existing_name = dock.find_app(app_info['CFBundleIdentifier'])
166
+
167
+ if existing_index
168
+ $stderr.puts "Warning: '#{existing_name}' is already in the Dock, skipping"
169
+ next
170
+ end
171
+
172
+ # Create the app tile
173
+ app_tile = TileFactory.create_app_tile(app_path, app_info)
174
+
175
+ if last_inserted_app_element
176
+ dock.apps_array.insert_after(last_inserted_app_element, app_tile)
177
+ last_inserted_app_element = app_tile
178
+ else
179
+ dock.apps_array.add(app_tile)
180
+ end
181
+
182
+ messages << "'#{app_name}' added to Dock."
183
+
184
+ else
185
+ # Search for app by name
186
+ app_path = AppFinder.find_app_on_disk(item_query)
187
+ unless app_path
188
+ $stderr.puts "Error: App '#{item_query}' not found"
189
+ exit 1
190
+ end
191
+
192
+ app_info = PlistReader.read_app_info(app_path)
193
+ unless app_info && app_info['CFBundleIdentifier']
194
+ $stderr.puts "Error: Could not read app info from '#{app_path}'"
195
+ exit 1
196
+ end
197
+
198
+ app_name = app_info['CFBundleName'] || app_info['CFBundleDisplayName'] || File.basename(app_path, '.app')
199
+
200
+ # Check if app is already in dock
201
+ existing_index, existing_name = dock.find_app(app_info['CFBundleIdentifier'])
202
+
203
+ if existing_index
204
+ $stderr.puts "Warning: '#{existing_name}' is already in the Dock, skipping"
205
+ next
206
+ end
207
+
208
+ # Create the app tile
209
+ app_tile = TileFactory.create_app_tile(app_path, app_info)
210
+
211
+ if last_inserted_app_element
212
+ dock.apps_array.insert_after(last_inserted_app_element, app_tile)
213
+ last_inserted_app_element = app_tile
214
+ else
215
+ dock.apps_array.add(app_tile)
216
+ end
217
+
218
+ messages << "'#{app_name}' added to Dock."
219
+ end
220
+ end
221
+
222
+ if messages.empty?
223
+ $stderr.puts "No items were added to the Dock"
224
+ exit 1
225
+ end
226
+
227
+ dock.save(messages)
228
+ end
229
+
230
+ # Implementation of the `remove` subcommand.
231
+ #
232
+ # Removes apps and/or folders from the Dock by name, bundle identifier,
233
+ # or path.
234
+ #
235
+ # @param args [Array<String>] Command-line arguments for the subcommand.
236
+ # @return [void]
237
+ def self.remove(args)
238
+ parser = CLI.remove_parser({})
239
+ parser.order!(args)
240
+
241
+ if args.empty?
242
+ $stderr.puts parser
243
+ exit 1
244
+ end
245
+
246
+ dock = Dock.new
247
+ messages = []
248
+
249
+ # Process each argument
250
+ args.each do |item_query|
251
+ found = false
252
+
253
+ # Determine if this looks like a path
254
+ is_path = item_query.include?('/')
255
+
256
+ # First check persistent-apps
257
+ index, name = dock.find_app(item_query)
258
+
259
+ if index
260
+ app_dicts = dock.get_app_dicts
261
+ dock.apps_array.delete(app_dicts[index])
262
+ messages << "'#{name}' removed from Dock."
263
+ found = true
264
+ end
265
+
266
+ # If not found in apps, check persistent-others (folders)
267
+ unless found
268
+ index, name = dock.find_folder(item_query, check_url: is_path)
269
+
270
+ if index
271
+ folder_dicts = dock.get_folder_dicts
272
+ dock.others_array.delete(folder_dicts[index])
273
+ messages << "'#{name}' removed from Dock."
274
+ found = true
275
+ end
276
+ end
277
+
278
+ unless found
279
+ $stderr.puts "Warning: '#{item_query}' not found in Dock, skipping"
280
+ end
281
+ end
282
+
283
+ if messages.empty?
284
+ $stderr.puts "No items were removed from the Dock"
285
+ exit 1
286
+ end
287
+
288
+ dock.save(messages)
289
+ end
290
+
291
+ # Implementation of the `move` subcommand.
292
+ #
293
+ # Moves an existing Dock item after another item in the same section.
294
+ #
295
+ # @param args [Array<String>] Command-line arguments for the subcommand.
296
+ # @return [void]
297
+ def self.move(args)
298
+ # Accept either order: move --after TARGET ITEM or move ITEM --after TARGET
299
+ options = { after: nil }
300
+ parser = CLI.move_parser(options)
301
+ parser.permute!(args)
302
+
303
+ # Now, args should contain the non-option arguments (either [item] or [target, item] or [item, target])
304
+ after_target = options[:after]
305
+
306
+ # Accept either order: --after TARGET ITEM or ITEM --after TARGET
307
+ item_query = nil
308
+ if after_target && args.length == 1
309
+ item_query = args.first
310
+ elsif after_target && args.length == 2
311
+ # Try to infer which is the item and which is the target
312
+ if args[0].downcase == after_target.downcase
313
+ item_query = args[1]
314
+ elsif args[1].downcase == after_target.downcase
315
+ item_query = args[0]
316
+ else
317
+ # Default: treat first as item
318
+ item_query = args[0]
319
+ end
320
+ else
321
+ $stderr.puts parser
322
+ $stderr.puts "\nError: You must specify an item to move and a target with --after."
323
+ exit 1
324
+ end
325
+
326
+ if !after_target || !item_query
327
+ $stderr.puts parser
328
+ $stderr.puts "\nError: You must specify an item to move and a target with --after."
329
+ exit 1
330
+ end
331
+
332
+ dock = Dock.new
333
+
334
+ # Find the item to move (check apps first, then folders)
335
+ move_array, move_element, move_name = dock.find_item(item_query, check_url: true)
336
+
337
+ unless move_element
338
+ $stderr.puts "Error: '#{item_query}' not found in Dock"
339
+ exit 1
340
+ end
341
+
342
+ # Find the target (check apps first, then folders)
343
+ after_array, after_element, after_name = dock.find_item(after_target, check_url: true)
344
+
345
+ unless after_element
346
+ $stderr.puts "Error: '#{after_target}' not found in Dock"
347
+ exit 1
348
+ end
349
+
350
+ # Check if they're the same item
351
+ if move_element == after_element
352
+ $stderr.puts "Error: Cannot move an item after itself"
353
+ exit 1
354
+ end
355
+
356
+ # Check if moving between arrays (apps <-> folders) - not allowed
357
+ if move_array != after_array
358
+ $stderr.puts "Error: Cannot move items between apps and folders sections"
359
+ exit 1
360
+ end
361
+
362
+ # Remove from current position
363
+ move_array.delete(move_element)
364
+
365
+ # Insert after target
366
+ move_array.insert_after(after_element, move_element)
367
+
368
+ dock.save("'#{move_name}' moved after '#{after_name}'.")
369
+ end
370
+ end
371
+ end
372
+
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockEdit
4
+ # Constants used across the dockedit implementation.
5
+ module Constants
6
+ # Absolute path to the Dock preferences plist.
7
+ #
8
+ # In tests this constant is overridden to point at a temporary copy.
9
+ DOCK_PLIST = File.expand_path('~/Library/Preferences/com.apple.dock.plist')
10
+
11
+ # Mapping of short folder names to full filesystem paths.
12
+ #
13
+ # Keys are regular expressions matched against user input; values are
14
+ # expanded paths.
15
+ PATH_SHORTCUTS = {
16
+ /^desktop$/i => File.expand_path('~/Desktop'),
17
+ /^downloads$/i => File.expand_path('~/Downloads'),
18
+ /^(home|~)$/i => File.expand_path('~'),
19
+ /^library$/i => File.expand_path('~/Library'),
20
+ /^documents$/i => File.expand_path('~/Documents'),
21
+ /^(applications|apps)$/i => '/Applications',
22
+ /^sites$/i => File.expand_path('~/Sites')
23
+ }.freeze
24
+
25
+ # Mapping of show-as string aliases to integer values used in the plist.
26
+ #
27
+ # 1 = fan, 2 = grid, 3 = list, 4 = auto.
28
+ SHOW_AS_VALUES = {
29
+ 'f' => 1, 'fan' => 1,
30
+ 'g' => 2, 'grid' => 2,
31
+ 'l' => 3, 'list' => 3,
32
+ 'a' => 4, 'auto' => 4
33
+ }.freeze
34
+
35
+ # Mapping of display-as string aliases to integer values used in the plist.
36
+ #
37
+ # 0 = stack, 1 = folder.
38
+ DISPLAY_AS_VALUES = {
39
+ 's' => 0, 'stack' => 0,
40
+ 'f' => 1, 'folder' => 1
41
+ }.freeze
42
+ end
43
+ end
44
+