labrat 0.1.13

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,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