mdlogbook 0.1.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.
Files changed (90) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +80 -0
  3. data/CHANGELOG.md +16 -0
  4. data/CLAUDE.md +5 -0
  5. data/CODE_OF_CONDUCT.md +10 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +99 -0
  8. data/Rakefile +21 -0
  9. data/docs/sample-logs/sample-2025-01.md +122 -0
  10. data/docs/sample-logs/sample-2025-02.md +123 -0
  11. data/docs/sample-logs/sample-2025-03.md +116 -0
  12. data/docs/sample-logs/sample-2025-04.md +128 -0
  13. data/docs/sample-logs/sample-2025-05.md +122 -0
  14. data/docs/sample-logs/sample-2025-06.md +113 -0
  15. data/docs/sample-logs/sample-2025-07.md +122 -0
  16. data/docs/sample-logs/sample-2025-08.md +94 -0
  17. data/docs/sample-logs/sample-2025-09.md +119 -0
  18. data/docs/sample-logs/sample-2025-10.md +123 -0
  19. data/docs/sample-logs/sample-2025-11.md +113 -0
  20. data/docs/sample-logs/sample-2025-12.md +121 -0
  21. data/docs/usage.md +778 -0
  22. data/exe/mlb +6 -0
  23. data/lib/mdlogbook/arg_parser.rb +372 -0
  24. data/lib/mdlogbook/brace_expander.rb +59 -0
  25. data/lib/mdlogbook/cli.rb +224 -0
  26. data/lib/mdlogbook/config.rb +247 -0
  27. data/lib/mdlogbook/date_filter.rb +22 -0
  28. data/lib/mdlogbook/editor_launcher.rb +90 -0
  29. data/lib/mdlogbook/env.rb +95 -0
  30. data/lib/mdlogbook/file_resolver.rb +43 -0
  31. data/lib/mdlogbook/formatter/anonymize.rb +75 -0
  32. data/lib/mdlogbook/formatter/anonymize_dict.rb +36 -0
  33. data/lib/mdlogbook/formatter/base.rb +138 -0
  34. data/lib/mdlogbook/formatter/calendar.rb +154 -0
  35. data/lib/mdlogbook/formatter/chart_series_builder.rb +57 -0
  36. data/lib/mdlogbook/formatter/extract.rb +41 -0
  37. data/lib/mdlogbook/formatter/grid.rb +256 -0
  38. data/lib/mdlogbook/formatter/heatmap.rb +142 -0
  39. data/lib/mdlogbook/formatter/line_chart.rb +156 -0
  40. data/lib/mdlogbook/formatter/simple.rb +42 -0
  41. data/lib/mdlogbook/option_def.rb +68 -0
  42. data/lib/mdlogbook/option_registry.rb +205 -0
  43. data/lib/mdlogbook/overlap_checker.rb +42 -0
  44. data/lib/mdlogbook/parser.rb +213 -0
  45. data/lib/mdlogbook/period_spec.rb +56 -0
  46. data/lib/mdlogbook/raw_extractor.rb +100 -0
  47. data/lib/mdlogbook/runner/base.rb +66 -0
  48. data/lib/mdlogbook/runner/editor_runner.rb +49 -0
  49. data/lib/mdlogbook/runner/extract_format_runner.rb +54 -0
  50. data/lib/mdlogbook/runner/standard_format_runner.rb +105 -0
  51. data/lib/mdlogbook/runner/visual_format_runner.rb +77 -0
  52. data/lib/mdlogbook/runner.rb +27 -0
  53. data/lib/mdlogbook/section_extractor.rb +140 -0
  54. data/lib/mdlogbook/version.rb +6 -0
  55. data/lib/mdlogbook/work_thresholds.rb +37 -0
  56. data/lib/mdlogbook.rb +41 -0
  57. data/sig/mdlogbook/arg_parser.rbs +54 -0
  58. data/sig/mdlogbook/brace_expander.rbs +4 -0
  59. data/sig/mdlogbook/cli.rbs +14 -0
  60. data/sig/mdlogbook/config.rbs +60 -0
  61. data/sig/mdlogbook/date_filter.rbs +4 -0
  62. data/sig/mdlogbook/editor_launcher.rbs +9 -0
  63. data/sig/mdlogbook/env.rbs +32 -0
  64. data/sig/mdlogbook/file_resolver.rbs +9 -0
  65. data/sig/mdlogbook/formatter/anonymize.rbs +4 -0
  66. data/sig/mdlogbook/formatter/anonymize_dict.rbs +4 -0
  67. data/sig/mdlogbook/formatter/base.rbs +15 -0
  68. data/sig/mdlogbook/formatter/calendar.rbs +4 -0
  69. data/sig/mdlogbook/formatter/chart_series_builder.rbs +9 -0
  70. data/sig/mdlogbook/formatter/extract.rbs +4 -0
  71. data/sig/mdlogbook/formatter/grid.rbs +8 -0
  72. data/sig/mdlogbook/formatter/heatmap.rbs +10 -0
  73. data/sig/mdlogbook/formatter/line_chart.rbs +7 -0
  74. data/sig/mdlogbook/formatter/simple.rbs +6 -0
  75. data/sig/mdlogbook/option_def.rbs +30 -0
  76. data/sig/mdlogbook/option_registry.rbs +18 -0
  77. data/sig/mdlogbook/overlap_checker.rbs +13 -0
  78. data/sig/mdlogbook/parser.rbs +32 -0
  79. data/sig/mdlogbook/period_spec.rbs +11 -0
  80. data/sig/mdlogbook/raw_extractor.rbs +15 -0
  81. data/sig/mdlogbook/runner/base.rbs +4 -0
  82. data/sig/mdlogbook/runner/editor_runner.rbs +5 -0
  83. data/sig/mdlogbook/runner/extract_format_runner.rbs +4 -0
  84. data/sig/mdlogbook/runner/standard_format_runner.rbs +4 -0
  85. data/sig/mdlogbook/runner/visual_format_runner.rbs +4 -0
  86. data/sig/mdlogbook/runner.rbs +4 -0
  87. data/sig/mdlogbook/section_extractor.rbs +24 -0
  88. data/sig/mdlogbook/work_thresholds.rbs +11 -0
  89. data/sig/mdlogbook.rbs +8 -0
  90. metadata +149 -0
data/exe/mlb ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "mdlogbook"
5
+
6
+ exit(Mdlogbook::CLI.new.run ? 0 : 1)
@@ -0,0 +1,372 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Mdlogbook
6
+ # Parses command-line arguments into an ordered list of {PeriodSpec} objects.
7
+ #
8
+ # Supported forms (after option parsing):
9
+ # (no args) current month
10
+ # YYYY/M a single month
11
+ # YYYY-M a single month (dash separator)
12
+ # YYYY/M YYYY/M inclusive month range
13
+ # YYYY-MM-DD a single day (also YYYY/MM/DD)
14
+ # 'YYYY/0*' partial wildcard month (matched against available files)
15
+ # '*/*' all available months across all years
16
+ # 'YYYY/{N..M}' month glob (bash/zsh brace expansion)
17
+ # 'YYYY/*' all months of a year
18
+ # m / m0 / mN / m-N relative calendar month (0=current, positive=past, negative=future)
19
+ # y / y0 / yN / y-N relative calendar year (expands to individual month specs)
20
+ # q / q0 / qN / q-N relative calendar quarter (expands to 3 month specs)
21
+ # w / w0 / wN / w-N relative calendar week (Monday-start)
22
+ # d / d0 / dN / d-N relative calendar day (point)
23
+ # DN duration: last N days including today
24
+ # wN..M simplified range shorthand for w{N..M}
25
+ # w.. open-ended shorthand for w{0..}
26
+ # w1.. open-ended shorthand for w{1..}
27
+ # 'w{0..1}' relative glob (expands to w0, w1, ...)
28
+ # 'y{1..}' open-ended glob (uses filesystem to find oldest available)
29
+ class ArgParser
30
+ DATE_RE = /\A(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})\z/
31
+ MONTH_RE = /\A(\d{4})[\/-](\d{1,2})\z/
32
+ DURATION_RE = /\AD(\d+)\z/
33
+ RELATIVE_UNIT_RE = /\A([dwmyq])(.*)\z/i
34
+ BRACED_RELATIVE_RANGE_RE = /\A\{(\d*)\.\.(\d*)\}\z/
35
+ SIMPLE_RELATIVE_RANGE_RE = /\A(\d*)\.\.(\d*)\z/
36
+
37
+ # Abstract base class for normalized argument directives.
38
+ #
39
+ # A directive captures the meaning of one command-line token independently
40
+ # of its surface syntax, then resolves itself into one or more {PeriodSpec}
41
+ # objects with help from {DirectiveContext}.
42
+ class Directive
43
+ def resolve_with(_resolver)
44
+ raise NotImplementedError, "#{self.class} must implement #resolve_with"
45
+ end
46
+ end
47
+
48
+ # Normalized directive representing an explicit single-day target.
49
+ class DateDirective < Directive
50
+ def initialize(date)
51
+ @date = date
52
+ end
53
+
54
+ def resolve_with(_resolver)
55
+ [PeriodSpec.new(@date, @date)]
56
+ end
57
+ end
58
+
59
+ # Normalized directive representing an explicit calendar month.
60
+ class MonthDirective < Directive
61
+ def initialize(year, month)
62
+ @year = year
63
+ @month = month
64
+ end
65
+
66
+ def resolve_with(_resolver)
67
+ [PeriodSpec.month(@year, @month)]
68
+ end
69
+ end
70
+
71
+ # Normalized directive representing a trailing duration like D7.
72
+ class RelativeDurationDirective < Directive
73
+ def initialize(value)
74
+ @value = value
75
+ end
76
+
77
+ def resolve_with(resolver)
78
+ [PeriodSpec.new(resolver.today - (@value - 1), resolver.today, relative: true)]
79
+ end
80
+ end
81
+
82
+ # Normalized directive representing a single relative point such as m1 or w0.
83
+ class RelativePointDirective < Directive
84
+ def initialize(unit, value)
85
+ @unit = unit
86
+ @value = value
87
+ end
88
+
89
+ def resolve_with(resolver)
90
+ Array(resolve_relative_point(resolver))
91
+ end
92
+
93
+ private
94
+
95
+ def resolve_relative_point(resolver)
96
+ today = resolver.today
97
+ case @unit
98
+ when "m" then relative_month(today)
99
+ when "y" then relative_year(today)
100
+ when "w" then relative_week(today)
101
+ when "d" then relative_day(today)
102
+ when "q" then relative_quarter(today)
103
+ end
104
+ end
105
+
106
+ def relative_month(today)
107
+ first = Date.new(today.year, today.month, 1)
108
+ target = (@value >= 0) ? first.prev_month(@value) : first.next_month(-@value)
109
+ PeriodSpec.month(target.year, target.month, relative: true)
110
+ end
111
+
112
+ def relative_year(today)
113
+ year = today.year - @value
114
+ (1..12).map { |month| PeriodSpec.month(year, month, relative: true) }
115
+ end
116
+
117
+ def relative_week(today)
118
+ monday = today - (today.wday - 1) % 7
119
+ start = monday - (7 * @value)
120
+ PeriodSpec.new(start, start + 6, relative: true)
121
+ end
122
+
123
+ def relative_day(today)
124
+ date = today - @value
125
+ PeriodSpec.new(date, date, relative: true)
126
+ end
127
+
128
+ def relative_quarter(today)
129
+ q_start_month = today.month - ((today.month - 1) % 3)
130
+ first = Date.new(today.year, q_start_month, 1)
131
+ target_first = (@value >= 0) ? first.prev_month(@value * 3) : first.next_month(-@value * 3)
132
+ (0..2).map do |i|
133
+ d = target_first.next_month(i)
134
+ PeriodSpec.month(d.year, d.month, relative: true)
135
+ end
136
+ end
137
+ end
138
+
139
+ # Normalized directive representing a bounded relative range such as w0..2.
140
+ class RelativeRangeDirective < Directive
141
+ def initialize(unit, from, to)
142
+ @unit = unit
143
+ @from = from
144
+ @to = to
145
+ end
146
+
147
+ def resolve_with(resolver)
148
+ expand_relative_range(resolver)
149
+ end
150
+
151
+ private
152
+
153
+ def expand_relative_range(resolver)
154
+ (@from..@to).flat_map { |n| Array(RelativePointDirective.new(@unit, n).resolve_with(resolver)) }
155
+ end
156
+ end
157
+
158
+ # Normalized directive representing an open-ended relative range such as y1...
159
+ class RelativeOpenEndedDirective < Directive
160
+ def initialize(unit, from)
161
+ @unit = unit
162
+ @from = from
163
+ end
164
+
165
+ def resolve_with(resolver)
166
+ parse_open_ended(resolver)
167
+ end
168
+
169
+ private
170
+
171
+ def parse_open_ended(resolver)
172
+ cutoff_date = if @unit == "y"
173
+ Date.new(resolver.today.year - @from, 12, 31)
174
+ else
175
+ Array(RelativePointDirective.new(@unit, @from).resolve_with(resolver)).first.end_date
176
+ end
177
+
178
+ resolver.list_available_months
179
+ .select { |year, month| Date.new(year, month, 1) <= cutoff_date }
180
+ .map { |year, month| PeriodSpec.month(year, month, relative: true) }
181
+ end
182
+ end
183
+
184
+ # Normalized directive representing wildcard-based month selection.
185
+ class WildcardDirective < Directive
186
+ def initialize(pattern)
187
+ @pattern = pattern
188
+ end
189
+
190
+ def resolve_with(resolver)
191
+ expand_or_resolve_wildcard(resolver)
192
+ end
193
+
194
+ private
195
+
196
+ def expand_or_resolve_wildcard(resolver)
197
+ tokens = resolver.expander.expand(@pattern)
198
+ return resolve_wildcard(resolver) if tokens == [@pattern]
199
+
200
+ parser = resolver.new_parser
201
+ tokens.flat_map { |token| parser.parse([token]) }
202
+ end
203
+
204
+ def resolve_wildcard(resolver)
205
+ unless @pattern.include?("/")
206
+ raise ArgumentError, "unresolvable wildcard: #{@pattern.inspect}"
207
+ end
208
+
209
+ resolver.list_available_months
210
+ .select { |year, month| File.fnmatch(@pattern, format("%04d/%02d", year, month), File::FNM_PATHNAME) }
211
+ .map { |year, month| PeriodSpec.month(year, month) }
212
+ end
213
+ end
214
+
215
+ # Resolves normalized directives into concrete {PeriodSpec} objects.
216
+ #
217
+ # Bundles the external resources that directives need during resolution
218
+ # (reference date, brace expander, file resolver), keeping {ArgParser}
219
+ # focused on interpreting command-line syntax.
220
+ class DirectiveContext
221
+ attr_reader :today, :expander
222
+
223
+ def initialize(parser:, today:, file_resolver:, expander:)
224
+ @parser = parser
225
+ @today = today
226
+ @file_resolver = file_resolver
227
+ @expander = expander
228
+ end
229
+
230
+ def resolve(directive)
231
+ directive.resolve_with(self)
232
+ end
233
+
234
+ def list_available_months
235
+ raise ArgumentError, "a FileResolver is required for this operation" unless @file_resolver
236
+
237
+ @file_resolver.list_available_months
238
+ end
239
+
240
+ def new_parser
241
+ ArgParser.new(today: @today, file_resolver: @file_resolver)
242
+ end
243
+ end
244
+
245
+ # @param today [Date] Reference date for relative specifiers (default: Date.today).
246
+ # @param file_resolver [FileResolver, nil] Used to resolve open-ended ranges.
247
+ def initialize(today: Date.today, file_resolver: nil)
248
+ @today = today
249
+ @file_resolver = file_resolver
250
+ @expander = BraceExpander.new
251
+ @context = DirectiveContext.new(
252
+ parser: self,
253
+ today: @today,
254
+ file_resolver: @file_resolver,
255
+ expander: @expander
256
+ )
257
+ end
258
+
259
+ # @param args [Array<String>]
260
+ # @return [Array<PeriodSpec>]
261
+ # @raise [ArgumentError] on unrecognised input.
262
+ def parse(args)
263
+ return [PeriodSpec.month(@today.year, @today.month)] if args.empty?
264
+
265
+ # Special case: exactly two plain month specifiers → range
266
+ if args.size == 2 && args.all? { |a| MONTH_RE.match?(a) }
267
+ return parse_month_range(args[0], args[1])
268
+ end
269
+
270
+ args.flat_map { |arg| parse_arg(arg) }
271
+ end
272
+
273
+ private
274
+
275
+ def parse_arg(arg)
276
+ @context.resolve(parse_directive(arg))
277
+ end
278
+
279
+ def parse_directive(arg)
280
+ parse_date_directive(arg) ||
281
+ parse_month_directive(arg) ||
282
+ parse_duration_directive(arg) ||
283
+ parse_relative_directive(arg) ||
284
+ parse_wildcard_directive(arg) ||
285
+ raise_unrecognised!(arg)
286
+ end
287
+
288
+ def parse_date_directive(arg)
289
+ match = DATE_RE.match(arg)
290
+ return unless match
291
+
292
+ DateDirective.new(Date.new(match[1].to_i, match[2].to_i, match[3].to_i))
293
+ end
294
+
295
+ def parse_month_directive(arg)
296
+ match = MONTH_RE.match(arg)
297
+ return unless match
298
+
299
+ MonthDirective.new(match[1].to_i, match[2].to_i)
300
+ end
301
+
302
+ def parse_duration_directive(arg)
303
+ match = DURATION_RE.match(arg)
304
+ return unless match
305
+
306
+ value = match[1].to_i
307
+ raise ArgumentError, "D0 is not a valid duration (use D1 for today)" if value.zero?
308
+
309
+ RelativeDurationDirective.new(value)
310
+ end
311
+
312
+ def parse_relative_directive(arg)
313
+ match = RELATIVE_UNIT_RE.match(arg)
314
+ return unless match
315
+
316
+ unit = match[1].downcase
317
+ body = match[2]
318
+
319
+ return RelativePointDirective.new(unit, 0) if body.empty?
320
+ return RelativePointDirective.new(unit, body.to_i) if body.match?(/\A-?\d+\z/)
321
+
322
+ from, to = parse_relative_range_body(body, arg)
323
+ return RelativeOpenEndedDirective.new(unit, from) if to.nil?
324
+
325
+ RelativeRangeDirective.new(unit, from, to)
326
+ end
327
+
328
+ def parse_relative_range_body(body, arg)
329
+ match = BRACED_RELATIVE_RANGE_RE.match(body) || SIMPLE_RELATIVE_RANGE_RE.match(body)
330
+ raise_invalid_relative_specifier!(arg) unless match
331
+
332
+ [relative_bound(match[1]), relative_bound(match[2], empty_default: nil)]
333
+ end
334
+
335
+ def relative_bound(value, empty_default: 0)
336
+ value.empty? ? empty_default : value.to_i
337
+ end
338
+
339
+ def parse_wildcard_directive(arg)
340
+ return unless arg.include?("{") || arg.include?("*")
341
+
342
+ WildcardDirective.new(arg)
343
+ end
344
+
345
+ def parse_month_range(from_str, to_str)
346
+ from_m = MONTH_RE.match(from_str)
347
+ to_m = MONTH_RE.match(to_str)
348
+ from = PeriodSpec.month(from_m[1].to_i, from_m[2].to_i)
349
+ to = PeriodSpec.month(to_m[1].to_i, to_m[2].to_i)
350
+
351
+ if from.start_date > to.start_date
352
+ raise ArgumentError, "start month must not be after end month"
353
+ end
354
+
355
+ specs = []
356
+ d = from.start_date
357
+ while d <= to.start_date
358
+ specs << PeriodSpec.month(d.year, d.month)
359
+ d = d.next_month
360
+ end
361
+ specs
362
+ end
363
+
364
+ def raise_invalid_relative_specifier!(arg)
365
+ raise ArgumentError, "invalid period specifier: #{arg.inspect}"
366
+ end
367
+
368
+ def raise_unrecognised!(arg)
369
+ raise ArgumentError, "unrecognised period specifier: #{arg.inspect}"
370
+ end
371
+ end
372
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mdlogbook
4
+ # Expands brace patterns (bash/zsh style) into an array of strings.
5
+ #
6
+ # Supported forms:
7
+ # {N..M} integer range (zero-padding preserved if either bound is zero-padded)
8
+ # {..M} range with start omitted (defaults to 0)
9
+ # {a,b,c} explicit list
10
+ # * month wildcard, equivalent to {01..12} after a slash
11
+ class BraceExpander
12
+ # Expands +pattern+ and returns an array of expanded strings.
13
+ # Open-ended ranges ({N..}) are not supported here; detect them before calling.
14
+ # @param pattern [String]
15
+ # @return [Array<String>]
16
+ def expand(pattern)
17
+ pattern = pattern.gsub(%r{(?<=/)\*\z}, "{01..12}")
18
+ expand_pattern(pattern)
19
+ end
20
+
21
+ private
22
+
23
+ def expand_pattern(str)
24
+ open_pos = str.index("{")
25
+ return [str] unless open_pos
26
+
27
+ close_pos = str.index("}", open_pos)
28
+ return [str] unless close_pos
29
+
30
+ prefix = str[0, open_pos]
31
+ suffix = str[close_pos + 1..]
32
+ content = str[open_pos + 1, close_pos - open_pos - 1]
33
+
34
+ expand_content(content).flat_map { |token| expand_pattern("#{prefix}#{token}#{suffix}") }
35
+ end
36
+
37
+ def expand_content(content)
38
+ if (m = content.match(/\A(-?\d*)\.\.(-?\d+)\z/))
39
+ expand_range(m[1], m[2])
40
+ else
41
+ content.split(",")
42
+ end
43
+ end
44
+
45
+ def expand_range(from_str, to_str)
46
+ from = from_str.empty? ? 0 : from_str.to_i
47
+ to = to_str.to_i
48
+ pad = zero_padded?(from_str) || zero_padded?(to_str)
49
+ width = [from_str.length, to_str.length].max
50
+
51
+ nums = (from <= to) ? (from..to).to_a : from.downto(to).to_a
52
+ nums.map { |n| pad ? n.to_s.rjust(width, "0") : n.to_s }
53
+ end
54
+
55
+ def zero_padded?(str)
56
+ str.length > 1 && str.start_with?("0")
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "date"
5
+ require "fileutils"
6
+ require "securerandom"
7
+
8
+ module Mdlogbook
9
+ # Command-line interface for mlb.
10
+ class CLI
11
+ # @param path [String, nil] Override path to include in the template.
12
+ # @return [String] YAML template for config file, respecting XDG_DATA_HOME.
13
+ def self.init_template(path: nil)
14
+ data_home = ENV.fetch("XDG_DATA_HOME", "~/.local/share")
15
+ log_path = path || "#{data_home}/mdlogbook/logs"
16
+ salt = SecureRandom.hex(16)
17
+ <<~YAML
18
+ mdlogbook:
19
+ path: #{log_path}
20
+ # target_hours: 160
21
+ anonymize:
22
+ salt: "#{salt}"
23
+ YAML
24
+ end
25
+
26
+ # Holds parsed command-line option flags and values.
27
+ Options = Struct.new(
28
+ :format, :regexp, :filter_regexp, :merge, :no_merge, :debug, :edit, :init, :list_projects,
29
+ :target_hours, :no_target_hours, :line_chart_size, :heatmap_resolution, :project,
30
+ :config_path, :path_override, :completion
31
+ ) do
32
+ def initialize(**)
33
+ super
34
+ self.merge ||= false
35
+ self.no_merge ||= false
36
+ self.debug ||= false
37
+ self.edit ||= false
38
+ self.init ||= false
39
+ self.no_target_hours ||= false
40
+ self.list_projects ||= false
41
+ end
42
+ end
43
+
44
+ # @param argv [Array<String>] Command-line arguments (default: ARGV).
45
+ # @param stdout [IO] Standard output stream (default: $stdout).
46
+ # @param stderr [IO] Standard error stream (default: $stderr).
47
+ # @param today [Date] Reference date for resolving relative period specifiers (default: Date.today).
48
+ def initialize(argv: ARGV, stdout: $stdout, stderr: $stderr, today: Date.today)
49
+ @argv = argv.dup
50
+ @stdout = stdout
51
+ @stderr = stderr
52
+ @today = today
53
+ @opts = Options.new
54
+ end
55
+
56
+ # Parses options, resolves period specifiers to files, then runs the formatter.
57
+ # @return [Boolean] true on success, false on error.
58
+ def run
59
+ parse_options!
60
+ return print_completion(@opts.completion) if @opts.completion
61
+ return setup_config if @opts.init
62
+
63
+ config = Config.new(config_path: @opts.config_path, project: @opts.project)
64
+ return print_projects(config) if @opts.list_projects
65
+
66
+ apply_config_defaults(config)
67
+ env = Env.new(opts: @opts, argv: @argv, stdout: @stdout, stderr: @stderr, today: @today, config: config)
68
+ Runner.dispatch(env: env)
69
+ true
70
+ rescue Mdlogbook::Error, ArgumentError, OptionParser::ParseError => e
71
+ if @opts.debug
72
+ @stderr.puts "Error: #{e.class}: #{e.message}"
73
+ @stderr.puts e.backtrace.join("\n")
74
+ else
75
+ @stderr.puts "Error: #{e.message}"
76
+ end
77
+ false
78
+ end
79
+
80
+ # Enables debug mode.
81
+ # @return [self]
82
+ def debug
83
+ @opts.debug = true
84
+ self
85
+ end
86
+
87
+ private
88
+
89
+ def setup_config
90
+ config_path = @opts.config_path || default_config_path
91
+ if File.exist?(config_path)
92
+ @stderr.puts "Error: config file already exists: #{config_path}"
93
+ return false
94
+ end
95
+ FileUtils.mkdir_p(File.dirname(config_path))
96
+ File.write(config_path, self.class.init_template(path: @opts.path_override))
97
+ @stdout.puts "Created config file: #{config_path}"
98
+ @stdout.puts "Please edit it to set your project path."
99
+ end
100
+
101
+ def print_projects(config)
102
+ config.projects.each { |name| @stdout.puts name }
103
+ end
104
+
105
+ def default_config_path
106
+ Config.default_config_path
107
+ end
108
+
109
+ VALID_DEFAULT_VIEWS = %w[simple calendar line_chart grid heatmap].freeze
110
+
111
+ def apply_config_defaults(config)
112
+ @opts.format ||= resolve_default_view(config)
113
+ @opts.target_hours ||= config.target_hours unless @opts.no_target_hours
114
+ end
115
+
116
+ def resolve_default_view(config)
117
+ view = config.default_view
118
+ return :simple unless view
119
+
120
+ unless VALID_DEFAULT_VIEWS.include?(view)
121
+ raise ArgumentError, "invalid default_view: #{view.inspect} (use #{VALID_DEFAULT_VIEWS.join(", ")})"
122
+ end
123
+
124
+ view.to_sym
125
+ end
126
+
127
+ def print_completion(shell)
128
+ case shell
129
+ when "bash" then @stdout.puts @registry.completion_bash
130
+ when "zsh" then @stdout.puts @registry.completion_zsh
131
+ else raise ArgumentError, "unknown shell: #{shell} (use bash or zsh)"
132
+ end
133
+ end
134
+
135
+ def parse_options!
136
+ @registry = build_registry
137
+ @registry.to_option_parser(banner: <<~TEXT).parse!(@argv)
138
+ Usage: mlb [options] [period...]
139
+
140
+ Primary mode:
141
+ Choose one of `-e`, `--raw`, `--grep`, or a summary view option.
142
+ If no view option is given, mlb uses the default summary view.
143
+ `--filter` applies only to summary views.
144
+ TEXT
145
+ end
146
+
147
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
148
+ def build_registry
149
+ reg = OptionRegistry.new
150
+
151
+ reg.separator "Edit:"
152
+ reg.option(short: "-e", long: "--edit", description: "open the target file in an editor") { @opts.edit = true }
153
+
154
+ reg.separator "Summary Views:"
155
+ reg.option(short: "-s", long: "--simple",
156
+ description: "show entries as a flat list (default)") { @opts.format = :simple }
157
+ reg.option(short: "-c", long: "--calendar",
158
+ description: "show entries as a monthly calendar") { @opts.format = :calendar }
159
+ reg.option(short: "-l", long: "--line-chart", arg_name: "SIZE", arg_optional: true, type: String,
160
+ values: %w[small s medium m large l max x],
161
+ description: "show cumulative hours as a line chart; SIZE: small/s, medium/m, large/l, max/x (default)") do |v|
162
+ @opts.format = :line_chart
163
+ @opts.line_chart_size = v
164
+ end
165
+ reg.option(long: "--grid",
166
+ description: "show charts in a 3-column grid layout (up to 12 months)") { @opts.format = :grid }
167
+ reg.option(long: "--heatmap", arg_name: "RESOLUTION", arg_optional: true, type: String,
168
+ values: %w[15m 30m 1h],
169
+ description: "show day-of-week x hour-of-day density plot; RESOLUTION: 15m, 30m (default), 1h") do |v|
170
+ @opts.format = :heatmap
171
+ @opts.heatmap_resolution = v
172
+ end
173
+ reg.option(long: "--anonymize",
174
+ description: "output anonymized markdown for AI analysis") { @opts.format = :anonymize }
175
+ reg.option(long: "--anonymize-dict",
176
+ description: "output reverse-lookup dictionary for anonymized jobs") { @opts.format = :anonymize_dict }
177
+ reg.option(long: "--filter", arg_name: "REGEXP", type: Regexp,
178
+ description: "filter aggregated entries by job name") { |v| @opts.filter_regexp = v }
179
+ reg.option(long: "--merge",
180
+ description: "combine all periods into a single result") { @opts.merge = true }
181
+ reg.option(long: "--no-merge",
182
+ description: "show each period separately (overrides default merge)") { @opts.no_merge = true }
183
+ reg.option(long: "--target-hours", arg_name: "HOURS", type: Numeric,
184
+ description: "override monthly target hours from config") { |v| @opts.target_hours = v }
185
+ reg.option(long: "--no-target-hours",
186
+ description: "disable target hours display") { @opts.no_target_hours = true }
187
+
188
+ reg.separator "Extract:"
189
+ reg.option(long: "--raw",
190
+ description: "output raw markdown content for AI summarization") { @opts.format = :raw }
191
+ reg.option(long: "--grep", arg_name: "REGEXP", type: Regexp,
192
+ description: "extract sections whose headings match REGEXP") do |v|
193
+ @opts.format = :grep
194
+ @opts.regexp = v
195
+ end
196
+
197
+ reg.separator "Configuration:"
198
+ reg.option(short: "-p", long: "--project", arg_name: "NAME", type: String,
199
+ completion: "$(mlb --list-projects 2>/dev/null)",
200
+ description: "select project (overrides MDLOGBOOK_PROJECT)") { |v| @opts.project = v }
201
+ reg.option(long: "--list-projects",
202
+ description: "list all configured project names") { @opts.list_projects = true }
203
+ reg.option(long: "--config", arg_name: "PATH", type: String, completion: :file,
204
+ description: "config file path (overrides MDLOGBOOK_CONFIG)") { |v| @opts.config_path = v }
205
+ reg.option(long: "--path", arg_name: "PATH", type: String, completion: :directory,
206
+ description: "log directory path (overrides config)") { |v| @opts.path_override = v }
207
+ reg.option(long: "--init",
208
+ description: "create a config file template") { @opts.init = true }
209
+
210
+ reg.separator "Other:"
211
+ reg.option(long: "--completion", arg_name: "SHELL", type: String, values: %w[bash zsh],
212
+ description: "print shell completion script") { |v| @opts.completion = v }
213
+ reg.option(long: "--debug",
214
+ description: "show internal diagnostics") { @opts.debug = true }
215
+ reg.option(short: "-h", long: "--help", description: "show help") do
216
+ @stdout.puts @registry.to_option_parser(banner: "")
217
+ exit
218
+ end
219
+
220
+ reg
221
+ end
222
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
223
+ end
224
+ end