labrat 0.1.13

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,24 @@
1
+ # User database of label settings to override or augment system-level
2
+ # settings. For labels known from the system-level config, you might want to
3
+ # add user-specific settings, such as the destination printer for that
4
+ # particular label type.
5
+ dymo30327:
6
+ printer-name: dymo
7
+
8
+ # Or you might want a full definition of a label type not defined in the
9
+ # system-level database. Here is a commented-out definition of a common avery
10
+ # label type that you can adapt for your own label type. Just give it a
11
+ # unique name unless you want to replace the system-level definition.
12
+
13
+ # avery5160:
14
+ # page-width: 8.5in
15
+ # page-height: 11in
16
+ # rows: 10
17
+ # columns: 3
18
+ # top-page-margin: 13mm
19
+ # bottom-page-margin: 12mm
20
+ # left-page-margin: 5mm
21
+ # right-page-margin: 5mm
22
+ # row-gap: 0mm
23
+ # column-gap: 3mm
24
+ # landscape: false
@@ -0,0 +1,481 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labrat
4
+ # An ArgParser object implements parsing of command-line arguments and
5
+ # gathering them into an Options object. By using the from_hash method, you
6
+ # can get an ArgParser object to also treat a Hash as if it were a set of
7
+ # command-line arguments so that config files, converted to a Hash can also
8
+ # be used with an ArgParser.
9
+ class ArgParser
10
+ attr_reader :parser, :options, :labels_seen
11
+
12
+ def initialize
13
+ @labels_seen = Set.new
14
+ @options = Labrat::Options.new
15
+ @parser = OptionParser.new
16
+ @parser.summary_width = 30
17
+ define_options
18
+ end
19
+
20
+ # Parse and set the options object to reflect the values of the given
21
+ # args, after merging in any prior settings into options. Return the
22
+ # resulting options instance. The args argument can be either a Hash or,
23
+ # as usual, an Array of Strings from the command-line.x Throw an exception
24
+ # for errors encountered parsing the args.
25
+ def parse(args, prior: {}, verbose: false)
26
+ options.msg = nil
27
+ options.verbose = verbose
28
+ options.merge!(prior)
29
+ case args
30
+ when Hash
31
+ parser.parse!(args.optionize)
32
+ when Array
33
+ parser.parse!(args)
34
+ else
35
+ raise "ArgParser cannot parse args of class '#{args.class}'"
36
+ end
37
+ options
38
+ rescue OptionParser::ParseError => e
39
+ options.msg = "Error: #{e}\n\nTry `labrat --help` for usage."
40
+ raise Labrat::OptionError, options.msg
41
+ end
42
+
43
+ private
44
+
45
+ # Define the OptionParser rules for acceptable options in Labrat.
46
+ def define_options
47
+ parser.banner = "Usage: labrat [options] <label-text>"
48
+ parser.separator ""
49
+ parser.separator "Print or view (with -V) a label with the given <label-text>."
50
+ parser.separator "All non-option arguments are used for the label text with a special"
51
+ parser.separator "marker ('++' by default, see --nlsep) indicating a line-break."
52
+ parser.separator ""
53
+ parser.separator "Below, NUM indicates an integer, DIM, indicates a linear dimension,"
54
+ parser.separator "valid DIM units are: pt, mm, cm, dm, m, in, ft, yd."
55
+ parser.separator "A DIM with no units assumes pt (points)."
56
+ parser.separator ""
57
+ parser.separator "Meta options:"
58
+ label_name_option
59
+ list_labels_option
60
+
61
+ parser.separator ""
62
+ parser.separator "Page setup options:"
63
+ page_dimension_options
64
+ page_margin_options
65
+
66
+ parser.separator ""
67
+ parser.separator "Label setup options:"
68
+ landscape_option
69
+ portrait_option
70
+ padding_options
71
+ align_options
72
+ font_options
73
+ delta_options
74
+
75
+ parser.separator ""
76
+ parser.separator "Processing options:"
77
+ start_label_option
78
+ nl_sep_option
79
+ label_sep_option
80
+ copies_option
81
+ in_file_option
82
+ out_file_option
83
+ printer_name_option
84
+ command_options
85
+ view_option
86
+ grid_option
87
+ template_option
88
+ verbose_option
89
+
90
+ parser.separator ""
91
+ parser.separator "Common options:"
92
+ # Normally, options.msg is nil. If it is set to a string, we want
93
+ # the main program to print the string and exit.
94
+ options.msg = nil
95
+ parser.on_tail("--help", "Show this message") do
96
+ # NB: parser.to_s returns the usage message.
97
+ options.msg = parser.to_s
98
+ end
99
+ # Another typical switch to print the version.
100
+ parser.on_tail("--version", "Show version") do
101
+ options.msg = "labrat version #{VERSION}"
102
+ end
103
+ end
104
+
105
+ # Use the facilities of 'prawn/measurement_extensions' to convert
106
+ # dimensions given in pt, mm, cm, dm, m, in, ft, yd into Adobe points, or
107
+ # "big points" in TeX jargon.
108
+ def parse_dimension(str, where = '')
109
+ unless (match = str.match(/\A\s*(?<measure>[-+]?[0-9.]+)\s*(?<unit>[A-Za-z]*)\s*\z/))
110
+ raise Labrat::DimensionError, "illegal #{where} dimension: '#{str}'"
111
+ end
112
+
113
+ if match[:unit].empty?
114
+ match[:measure].to_f
115
+ else
116
+ meas = match[:measure].to_f
117
+ u_meth = match[:unit].to_sym
118
+ unless meas.respond_to?(u_meth)
119
+ msg = "Error: unknown #{where} unit: '#{match[:unit]}'\n"\
120
+ " valid units are: pt, mm, cm, dm, m, in, ft, yd"
121
+ raise Labrat::DimensionError, msg
122
+ end
123
+ points = meas.send(u_meth)
124
+ warn " ::#{where} <- #{str} (#{points.round(2)}pt)::" if options.verbose
125
+ points
126
+ end
127
+ end
128
+
129
+ # Define options for specifying the dimensions of a page of labels to be
130
+ # printed on.
131
+ def page_dimension_options
132
+ # Specifies an optional option argument
133
+ parser.on("-wDIM", "--page-width=DIM",
134
+ "Horizontal dimension of a page of labels as it comes out of the printer") do |wd|
135
+ options.page_width = parse_dimension(wd, 'page-width')
136
+ end
137
+ parser.on("-hDIM", "--page-height=DIM",
138
+ "Vertical dimension of a page of labels as it comes out of the printer") do |ht|
139
+ options.page_height = parse_dimension(ht, 'page-height')
140
+ end
141
+ parser.on("-RNUM", "--rows=NUM", Integer,
142
+ "Number of rows of labels on a page") do |n|
143
+ options.rows = n
144
+ warn " ::rows <- #{n}::" if options.verbose
145
+ end
146
+ parser.on("-CNUM", "--columns=NUM", Integer,
147
+ "Number of columns of labels on a page") do |n|
148
+ options.columns = n
149
+ warn " ::columns <- #{n}::" if options.verbose
150
+ end
151
+ parser.on("--row-gap=DIM",
152
+ "Vertical space between rows of labels on a page") do |gap|
153
+ options.row_gap = parse_dimension(gap, 'row-gap')
154
+ end
155
+ parser.on("--column-gap=DIM",
156
+ "Column gap:",
157
+ "Horizontal space between columns of labels on a page") do |gap|
158
+ options.column_gap = parse_dimension(gap, 'column-gap')
159
+ end
160
+ end
161
+
162
+ # Set the page margins for printing on a page of labels. Left, right,
163
+ # top, and bottom are named assuming a portrait orientation, that is the
164
+ # orientation of the page as it comes out of the printer.
165
+ def page_margin_options
166
+ parser.on("--right-page-margin=DIM",
167
+ "Distance from right side of page (in portrait) to print area") do |x|
168
+ options.right_page_margin = parse_dimension(x, 'right-page-margin')
169
+ end
170
+ parser.on("--left-page-margin=DIM",
171
+ "Distance from left side of page (in portrait) to print area") do |x|
172
+ options.left_page_margin = parse_dimension(x, 'left-page-margin')
173
+ end
174
+ parser.on("--top-page-margin=DIM",
175
+ "Distance from top side of page (in portrait) to print area") do |x|
176
+ options.top_page_margin = parse_dimension(x, 'top-page-margin')
177
+ end
178
+ parser.on("--bottom-page-margin=DIM",
179
+ "Distance from bottom side of page (in portrait) to print area") do |x|
180
+ options.bottom_page_margin = parse_dimension(x, 'bottom-page-margin')
181
+ end
182
+ parser.on("--h-page-margin=DIM",
183
+ "Distance from left and right sides of page (in portrait) to print area") do |x|
184
+ options.left_page_margin = options.right_page_margin = parse_dimension(x, 'h-page-margin')
185
+ end
186
+ parser.on("--v-page-margin=DIM",
187
+ "Distance from top and bottom sides of page (in portrait) to print area") do |x|
188
+ options.top_page_margin = options.bottom_page_margin = parse_dimension(x, 'v-page-margin')
189
+ end
190
+ parser.on("--page-margin=DIM",
191
+ "Distance from all sides of page (in portrait) to print area") do |x|
192
+ options.left_page_margin = options.right_page_margin =
193
+ options.top_page_margin = options.bottom_page_margin = parse_dimension(x, 'margin')
194
+ end
195
+ end
196
+
197
+ # Define a label name. Perhaps the config files could contain a database
198
+ # of common labels with their dimensions, so that --width and --height
199
+ # need not be specified.
200
+ def label_name_option
201
+ parser.on("-lNAME", "--label=NAME",
202
+ "Use options for label type NAME from label database") do |name|
203
+ if labels_seen.include?(name)
204
+ msg = "label option '#{name}' has circular reference"
205
+ labels_seen.each do |nm|
206
+ msg += "\n -> #{nm}"
207
+ end
208
+ msg += "\n -> #{name}"
209
+ raise RecursionError, msg
210
+ else
211
+ options.label = name
212
+ labels_seen << options.label
213
+ end
214
+
215
+ # Insert at this point the option args found in the Label.db
216
+ lab_hash = LabelDb[name.to_sym]
217
+ lab_hash.report("\nConfig from labeldb entry '#{name}'") if options.verbose
218
+ raise LabelNameError,
219
+ "Unknown label name '#{name}'." if lab_hash.empty?
220
+
221
+ lab_args = lab_hash.optionize
222
+ parse(lab_args, verbose: options.verbose)
223
+ end
224
+ end
225
+
226
+ def list_labels_option
227
+ parser.on("--list-labels",
228
+ "List known label types from label database and exit") do
229
+ db_paths = Labrat::LabelDb.db_paths
230
+ lab_names = Labrat::LabelDb.known_names
231
+ if db_paths.empty?
232
+ warn "Have you run labrat-install yet?"
233
+ else
234
+ warn "Label databases at:"
235
+ db_paths.each do |p|
236
+ warn "#{p}\n"
237
+ end
238
+ warn "\nKnown labels:\n"
239
+ lab_names.groups_of(6).each do |_n, grp|
240
+ warn " #{grp.join(', ')}"
241
+ end
242
+ end
243
+ exit(0)
244
+ end
245
+ end
246
+
247
+ # Set the name, size, and style of font.
248
+ def align_options
249
+ parser.on("--h-align=[left|center|right|justify]", [:left, :center, :right, :justify],
250
+ "Horizontal alignment of label text (default center)") do |al|
251
+ options.h_align = al.to_sym
252
+ warn " ::h-align <- #{al}::" if options.verbose
253
+ end
254
+ parser.on("--v-align=[top|center|bottom]", [:top, :center, :bottom],
255
+ "Vertical alignment of label text (default center)") do |al|
256
+ options.v_align = al.to_sym
257
+ warn " ::v-align <- #{al}::" if options.verbose
258
+ end
259
+ end
260
+
261
+ # Set the margins between the sides of the label and the bounding box to
262
+ # hold the text of the label. Left, right, top, and bottom are named
263
+ # assuming a portrait orientation, that is the orientation the label has
264
+ # as it comes out of the printer.
265
+ def padding_options
266
+ parser.on("--right-pad=DIM",
267
+ "Distance from right side of label (in portrait) to print area") do |x|
268
+ options.right_pad = parse_dimension(x, 'right-pad')
269
+ end
270
+ parser.on("--left-pad=DIM",
271
+ "Distance from left side of label (in portrait) to print area") do |x|
272
+ options.left_pad = parse_dimension(x, 'left-pad')
273
+ end
274
+ parser.on("--top-pad=DIM",
275
+ "Distance from top side of label (in portrait) to print area") do |x|
276
+ options.top_pad = parse_dimension(x, 'top-pad')
277
+ end
278
+ parser.on("--bottom-pad=DIM",
279
+ "Distance from bottom side of label (in portrait) to print area") do |x|
280
+ options.bottom_pad = parse_dimension(x, 'bottom-pad')
281
+ end
282
+ parser.on("--h-pad=DIM",
283
+ "Distance from left and right sides of label (in portrait) to print area") do |x|
284
+ options.left_pad = options.right_pad = parse_dimension(x, 'h-pad')
285
+ end
286
+ parser.on("--v-pad=DIM",
287
+ "Distance from top and bottom sides of label (in portrait) to print area") do |x|
288
+ options.top_pad = options.bottom_pad = parse_dimension(x, 'v-pad')
289
+ end
290
+ parser.on("--pad=DIM",
291
+ "Distance from all sides of label (in portrait) to print area") do |x|
292
+ options.left_pad = options.right_pad =
293
+ options.top_pad = options.bottom_pad = parse_dimension(x, 'pad')
294
+ end
295
+ end
296
+
297
+ # Set the name, size, and style of font.
298
+ def font_options
299
+ parser.on("--font-name=NAME",
300
+ "Name of font to use (default Helvetica)") do |nm|
301
+ options.font_name = nm
302
+ warn " ::font-name <- '#{nm}'::" if options.verbose
303
+ end
304
+ parser.on("--font-size=NUM",
305
+ "Size of font to use in points (default 12)") do |pt|
306
+ options.font_size = pt
307
+ warn " ::font-size <- #{pt}::" if options.verbose
308
+ end
309
+ parser.on("--font-style=[normal|bold|italic|bold-italic]",
310
+ %w[normal bold italic bold-italic],
311
+ "Style of font to use for text (default normal)") do |sty|
312
+ # Prawn requires :bold_italic, not :"bold-italic"
313
+ options.font_style = sty.tr('-', '_').to_sym
314
+ warn " ::font-style <- #{sty}::" if options.verbose
315
+ end
316
+ end
317
+
318
+ # Even with accurate dimensions for labels, a combination of drivers, PDF
319
+ # settings, and perhaps a particular printer may result in text not
320
+ # sitting precisely where the user intends on the printed label. These
321
+ # options tweak the PDF settings to compensate for any such anomalies.
322
+ def delta_options
323
+ parser.on('-xDIM', "--delta-x=DIM",
324
+ "Left-right adjustment as label text is oriented") do |x|
325
+ options.delta_x = parse_dimension(x, 'delta-x')
326
+ end
327
+ parser.on('-yDIM', "--delta-y=DIM",
328
+ "Up-down adjustment as label text is oriented") do |y|
329
+ options.delta_y = parse_dimension(y, 'delta-y')
330
+ end
331
+ end
332
+
333
+ # The name of the printer to send the job to.
334
+ def printer_name_option
335
+ parser.on("-pNAME", "--printer=NAME",
336
+ "Name of the label printer to print on") do |name|
337
+ options.printer = name
338
+ warn " ::printer <- '#{name}'::" if options.verbose
339
+ end
340
+ end
341
+
342
+ def start_label_option
343
+ parser.on("-SNUM", "--start-label=NUM", Integer,
344
+ "Start printing at label number NUM (starting at 1, left-to-right, top-to-bottom)",
345
+ " within first page only. Later pages always start at label 1.") do |n|
346
+ options.start_label = n
347
+ warn " ::start-label <- #{n}::" if options.verbose
348
+ end
349
+ end
350
+
351
+ # On a command-line, specifying where a line-break should occur is not
352
+ # convenient when shell interpretation and quoting rules are taken into
353
+ # account. This allows the user to use some distinctive marker ('++' by
354
+ # default) to designate where a line break should occur.
355
+ def nl_sep_option
356
+ parser.on("-nSEP", "--nl-sep=SEPARATOR",
357
+ "Specify text to be translated into a line-break (default '++')") do |nl|
358
+ options.nl_sep = nl
359
+ warn " ::nl-sep <- '#{nl}'::" if options.verbose
360
+ end
361
+ end
362
+
363
+ # On a command-line, specifying where a new label should be started. This
364
+ # allows the user to use some distinctive marker ('][' by default) to
365
+ # designate where a new label shoul be started.
366
+ def label_sep_option
367
+ parser.on("--label-sep=SEPARATOR",
368
+ "Specify text that indicates the start of a new label (default ']*[')") do |ls|
369
+ options.label-sep = ls
370
+ warn " ::label-sep <- '#{ls}'::" if options.verbose
371
+ end
372
+ end
373
+
374
+ def copies_option
375
+ parser.on("-cNUM", "--copies=NUM", Integer,
376
+ "Number of copies of each label to generate") do |n|
377
+ options.copies = n.abs
378
+ warn " ::copies <- #{options.copies}::" if options.verbose
379
+ end
380
+ end
381
+
382
+ # For batch printing of labels, the user might want to just feed a file of
383
+ # labels to be printed. This option allows a file name to be give.
384
+ def in_file_option
385
+ parser.on("-fFILENAME", "--in-file=FILENAME",
386
+ "Read labels from given file instead of command-line") do |file|
387
+ options.in_file = file.strip
388
+ warn " ::in-file <- '#{file}'::" if options.verbose
389
+ end
390
+ end
391
+
392
+ # For batch printing of labels, the user might want to just feed a file of
393
+ # labels to be printed. This option allows a file name to be give.
394
+ def out_file_option
395
+ parser.on("-oFILENAME", "--out-file=FILENAME",
396
+ "Put generated label in the given file") do |file|
397
+ file = file.strip
398
+ unless file =~ /\.pdf\z/i
399
+ file = "#{file}.pdf"
400
+ end
401
+ options.out_file = file
402
+ warn " ::out-file <- '#{file}'::" if options.verbose
403
+ end
404
+ end
405
+
406
+ def command_options
407
+ # NB: the % is supposed to remind me of the rollers on a printer
408
+ parser.on("-%PRINTCMD", "--print-command=PRINTCMD",
409
+ "Command to use for printing with %p for printer name; %o for label file name") do |cmd|
410
+ options.print_command = cmd.strip
411
+ warn " ::print-command <- '#{cmd}'::" if options.verbose
412
+ end
413
+ # NB: the : is supposed to remind me of two eyeballs viewing the PDF
414
+ parser.on("-:VIEWCMD", "--view-command=VIEWCMD",
415
+ "Command to use for viewing with %o for label file name") do |cmd|
416
+ options.view_command = cmd.strip
417
+ warn " ::view-command <- '#{cmd}'::" if options.verbose
418
+ end
419
+ end
420
+
421
+ # Whether the label ought to be printed in landscape orientation, that is,
422
+ # the text turned 90 degrees clockwise from the orientation the label has
423
+ # coming out of the printer.
424
+ def landscape_option
425
+ parser.on("-L", "--[no-]landscape",
426
+ "Orient label in landscape (default false), i.e., with the left of",
427
+ "the label text starting at the top as the label as printed") do |l|
428
+ options.landscape = l
429
+ warn " ::landscape <- #{l}::" if options.verbose
430
+ end
431
+ end
432
+
433
+ # The inverse of landscape, i.e., no rotation is done.
434
+ def portrait_option
435
+ parser.on("-P", "--[no-]portrait",
436
+ "Orient label in portrait (default true), i.e., left-to-right",
437
+ "top-to-bottom as the label as printed. Negated landscape") do |p|
438
+ options.landscape = !p
439
+ warn " portrait option executed as ::landscape <- #{!p}::" if options.verbose
440
+ end
441
+ end
442
+
443
+ # Whether to preview with the view command instead of print
444
+ def view_option
445
+ # Boolean switch.
446
+ parser.on("-V", "--[no-]view", "View rather than print") do |v|
447
+ options.view = v
448
+ warn " ::view <- #{v}::" if options.verbose
449
+ end
450
+ end
451
+
452
+ # Whether to add label grid outline to output
453
+ def grid_option
454
+ # Boolean switch.
455
+ parser.on("-g", "--[no-]grid", "Add grid lines to output") do |g|
456
+ options.grid = g
457
+ warn " ::grid <- #{g}::" if options.verbose
458
+ end
459
+ end
460
+
461
+ # Ignore any content from in-file, stdin, or the command-line and just
462
+ # produce a template showing the boundaries of each label on a page of
463
+ # labels.
464
+ def template_option
465
+ parser.on("-T", "--[no-]template",
466
+ "Print a template of a page of labels and ignore any content.") do |t|
467
+ options.template = t
468
+ warn " ::template <- #{t}::" if options.verbose
469
+ end
470
+ end
471
+
472
+ # Whether we ought to be blabby about what we're up to.
473
+ def verbose_option
474
+ # Boolean switch.
475
+ parser.on("-v", "--[no-]verbose", "Run verbosely") do |v|
476
+ options.verbose = v
477
+ warn " ::verbose <- #{v}::" if options.verbose
478
+ end
479
+ end
480
+ end
481
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labrat
4
+ # This class is responsible for finding a config files, reading them, and
5
+ # returning a Hash to reflect the configuration. We use YAML as the
6
+ # configuration format and look for the config file in the standard places.
7
+ class Config
8
+ # Return a Hash of the YAML-ized config files for app_name directories.
9
+ # Config file may be located in either the xdg locations (containing any
10
+ # variant of base: base, base.yml, or base.yaml) or in the classic
11
+ # locations (/etc/app_namerc, /etc/app_name, ~/.app_namerc~, or
12
+ # ~/.app_name/base[.ya?ml]). Return a hash that reflects the merging of
13
+ # those files according to the following priorities, from highest to
14
+ # lowest:
15
+ #
16
+ # 1. A config file pointed to by the environment variable APPNAME_CONFIG
17
+ # 2. User classic config files
18
+ # 3. User xdg config files for app_name,
19
+ # 4. A config file pointed to by the environment variable APPNAME_SYS_CONFIG
20
+ # 5. System classic config files,
21
+ # 6. System xdg config files for for app_name,
22
+ #
23
+ # If an environment variable is found, the search for xdg and classic
24
+ # config files is skipped. Any dir_prefix is pre-pended to search
25
+ # locations environment, xdg and classic config paths so you can run this
26
+ # on a temporary directory set up for testing.
27
+ def self.read(app_name, base: 'config', dir_prefix: '', xdg: true, verbose: false)
28
+ paths = config_paths(app_name, base: base, dir_prefix: dir_prefix, xdg: xdg)
29
+ sys_configs = paths[:system]
30
+ usr_configs = paths[:user]
31
+ merge_configs_from((sys_configs + usr_configs).compact, verbose: verbose)
32
+ end
33
+
34
+ def self.config_paths(app_name, base: 'config', dir_prefix: '', xdg: true)
35
+ sys_configs = []
36
+ sys_env_name = "#{app_name.upcase}_SYS_CONFIG"
37
+ if ENV[sys_env_name]
38
+ sys_fname = File.join(dir_prefix, File.expand_path(ENV[sys_env_name]))
39
+ sys_configs << sys_fname if File.readable?(sys_fname)
40
+ else
41
+ sys_configs +=
42
+ if xdg
43
+ find_xdg_sys_config_files(app_name, base, dir_prefix)
44
+ else
45
+ find_classic_sys_config_files(app_name, base, dir_prefix)
46
+ end
47
+ end
48
+
49
+ usr_configs = []
50
+ usr_env_name = "#{app_name.upcase}_CONFIG"
51
+ if ENV[usr_env_name]
52
+ usr_fname = File.join(dir_prefix, File.expand_path(ENV[usr_env_name]))
53
+ usr_configs << usr_fname if File.readable?(usr_fname)
54
+ else
55
+ usr_configs <<
56
+ if xdg
57
+ find_xdg_user_config_file(app_name, base, dir_prefix)
58
+ else
59
+ find_classic_user_config_file(app_name, dir_prefix)
60
+ end
61
+ end
62
+ { system: sys_configs.compact, user: usr_configs.compact }
63
+ end
64
+
65
+ # Merge the settings from the given Array of config files in order from
66
+ # lowest priority to highest priority, starting with an empty hash. Any
67
+ # values of the top-level hash that are themselves Hashes are merged
68
+ # recursively.
69
+ def self.merge_configs_from(files = [], verbose: false)
70
+ hash = {}
71
+ files.each do |f|
72
+ next unless File.readable?(f)
73
+
74
+ yml_hash = YAML.load(File.read(f))&.methodize || {}
75
+ yml_hash.report("Merging from file '#{f}") if verbose
76
+ hash.deep_merge!(yml_hash)
77
+ end
78
+ hash
79
+ end
80
+
81
+ ########################################################################
82
+ # XDG config files
83
+ ########################################################################
84
+
85
+ # From the XDG standard:
86
+ # Your application should store and load data and configuration files to/from
87
+ # the directories pointed by the following environment variables:
88
+ #
89
+ # $XDG_CONFIG_HOME (default: "$HOME/.config"): user-specific configuration files.
90
+ # $XDG_CONFIG_DIRS (default: "/etc/xdg"): precedence-ordered set of system configuration directories.
91
+
92
+ # Return the absolute path names of all XDG system config files for
93
+ # app_name with the basename variants of base. Return the lowest priority
94
+ # files first, highest last. Prefix the search locations with dir_prefix
95
+ # if given.
96
+ def self.find_xdg_sys_config_files(app_name, base, dir_prefix)
97
+ configs = []
98
+ xdg_search_dirs = ENV['XDG_CONFIG_DIRS']&.split(':')&.reverse || ['/etc/xdg']
99
+ xdg_search_dirs.each do |dir|
100
+ dir = File.expand_path(File.join(dir, app_name))
101
+ dir = File.join(dir_prefix, dir) unless dir_prefix.nil? || dir_prefix.strip.empty?
102
+ base = app_name if base.nil? || base.strip.empty?
103
+ base_candidates = [base.to_s, "#{base}.yml", "#{base}.yaml",
104
+ "#{base}.cfg", "#{base}.config"]
105
+ config_fname = base_candidates.find { |b| File.readable?(File.join(dir, b)) }
106
+ configs << File.join(dir, config_fname) if config_fname
107
+ end
108
+ configs
109
+ end
110
+
111
+ # Return the absolute path name of any XDG user config files for app_name
112
+ # with the basename variants of base. The XDG_CONFIG_HOME environment
113
+ # variable for the user configs is intended to be the name of a single xdg
114
+ # config directory, not a list of colon-separated directories as for the
115
+ # system config. Return the name of a config file for this app in
116
+ # XDG_CONFIG_HOME (or ~/.config by default). Prefix the search location
117
+ # with dir_prefix if given.
118
+ def self.find_xdg_user_config_file(app_name, base, dir_prefix)
119
+ dir_prefix ||= ''
120
+ base ||= (base&.strip || app_name)
121
+ xdg_search_dir = ENV['XDG_CONFIG_HOME'] || ['~/.config']
122
+ dir = File.expand_path(File.join(xdg_search_dir, app_name))
123
+ dir = File.join(dir_prefix, dir) unless dir_prefix.strip.empty?
124
+ return nil unless Dir.exist?(dir)
125
+
126
+ base_candidates = [base.to_s, "#{base}.yml", "#{base}.yaml",
127
+ "#{base}.cfg", "#{base}.config"]
128
+ config_fname = base_candidates.find { |b| File.readable?(File.join(dir, b)) }
129
+ if config_fname
130
+ File.join(dir, config_fname)
131
+ end
132
+ end
133
+
134
+ ########################################################################
135
+ # Classic config files
136
+ ########################################################################
137
+
138
+ # Return the absolute path names of all "classic" system config files for
139
+ # app_name with the basename variants of base. Return the lowest priority
140
+ # files first, highest last. Prefix the search locations with dir_prefix
141
+ # if given.
142
+ def self.find_classic_sys_config_files(app_name, base, dir_prefix)
143
+ dir_prefix ||= ''
144
+ configs = []
145
+ env_config = ENV["#{app_name.upcase}_SYS_CONFIG"]
146
+ if env_config && File.readable?((config = File.join(dir_prefix, File.expand_path(env_config))))
147
+ configs = [config]
148
+ elsif File.readable?(config = File.join(dir_prefix, "/etc/#{app_name}"))
149
+ configs = [config]
150
+ elsif File.readable?(config = File.join(dir_prefix, "/etc/#{app_name}rc"))
151
+ configs = [config]
152
+ else
153
+ dir = File.join(dir_prefix, "/etc/#{app_name}")
154
+ if Dir.exist?(dir)
155
+ base = app_name if base.nil? || base.strip.empty?
156
+ base_candidates = ["#{base}" "#{base}.yml", "#{base}.yaml",
157
+ "#{base}.cfg", "#{base}.config"]
158
+ config = base_candidates.find { |b| File.readable?(File.join(dir, b)) }
159
+ configs = [File.join(dir, config)] if config
160
+ end
161
+ end
162
+ configs
163
+ end
164
+
165
+ # Return the absolute path names of all "classic" system config files for
166
+ # app_name with the basename variants of base. Return the lowest priority
167
+ # files first, highest last. Prefix the search locations with dir_prefix if
168
+ # given.
169
+ def self.find_classic_user_config_file(app_name, dir_prefix)
170
+ dir_prefix ||= ''
171
+ config_fname = nil
172
+ env_config = ENV["#{app_name.upcase}_CONFIG"]
173
+ if env_config && File.readable?((config = File.join(dir_prefix, File.expand_path(env_config))))
174
+ config_fname = config
175
+ elsif Dir.exist?(config_dir = File.join(dir_prefix, File.expand_path("~/.#{app_name}")))
176
+ base_candidates = ["config.yml", "config.yaml", "config"]
177
+ base_fname = base_candidates.find { |b| File.readable?(File.join(config_dir, b)) }
178
+ config_fname = File.join(config_dir, base_fname)
179
+ elsif Dir.exist?(config_dir = File.join(dir_prefix, File.expand_path('~/')))
180
+ base_candidates = [".#{app_name}", ".#{app_name}rc", ".#{app_name}.yml", ".#{app_name}.yaml",
181
+ ".#{app_name}.cfg", ".#{app_name}.config"]
182
+ base_fname = base_candidates.find { |b| File.readable?(File.join(config_dir, b)) }
183
+ config_fname = File.join(config_dir, base_fname)
184
+ end
185
+ config_fname
186
+ end
187
+ end
188
+ end