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.
- checksums.yaml +7 -0
- data/AGENTS.md +80 -0
- data/CHANGELOG.md +16 -0
- data/CLAUDE.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +99 -0
- data/Rakefile +21 -0
- data/docs/sample-logs/sample-2025-01.md +122 -0
- data/docs/sample-logs/sample-2025-02.md +123 -0
- data/docs/sample-logs/sample-2025-03.md +116 -0
- data/docs/sample-logs/sample-2025-04.md +128 -0
- data/docs/sample-logs/sample-2025-05.md +122 -0
- data/docs/sample-logs/sample-2025-06.md +113 -0
- data/docs/sample-logs/sample-2025-07.md +122 -0
- data/docs/sample-logs/sample-2025-08.md +94 -0
- data/docs/sample-logs/sample-2025-09.md +119 -0
- data/docs/sample-logs/sample-2025-10.md +123 -0
- data/docs/sample-logs/sample-2025-11.md +113 -0
- data/docs/sample-logs/sample-2025-12.md +121 -0
- data/docs/usage.md +778 -0
- data/exe/mlb +6 -0
- data/lib/mdlogbook/arg_parser.rb +372 -0
- data/lib/mdlogbook/brace_expander.rb +59 -0
- data/lib/mdlogbook/cli.rb +224 -0
- data/lib/mdlogbook/config.rb +247 -0
- data/lib/mdlogbook/date_filter.rb +22 -0
- data/lib/mdlogbook/editor_launcher.rb +90 -0
- data/lib/mdlogbook/env.rb +95 -0
- data/lib/mdlogbook/file_resolver.rb +43 -0
- data/lib/mdlogbook/formatter/anonymize.rb +75 -0
- data/lib/mdlogbook/formatter/anonymize_dict.rb +36 -0
- data/lib/mdlogbook/formatter/base.rb +138 -0
- data/lib/mdlogbook/formatter/calendar.rb +154 -0
- data/lib/mdlogbook/formatter/chart_series_builder.rb +57 -0
- data/lib/mdlogbook/formatter/extract.rb +41 -0
- data/lib/mdlogbook/formatter/grid.rb +256 -0
- data/lib/mdlogbook/formatter/heatmap.rb +142 -0
- data/lib/mdlogbook/formatter/line_chart.rb +156 -0
- data/lib/mdlogbook/formatter/simple.rb +42 -0
- data/lib/mdlogbook/option_def.rb +68 -0
- data/lib/mdlogbook/option_registry.rb +205 -0
- data/lib/mdlogbook/overlap_checker.rb +42 -0
- data/lib/mdlogbook/parser.rb +213 -0
- data/lib/mdlogbook/period_spec.rb +56 -0
- data/lib/mdlogbook/raw_extractor.rb +100 -0
- data/lib/mdlogbook/runner/base.rb +66 -0
- data/lib/mdlogbook/runner/editor_runner.rb +49 -0
- data/lib/mdlogbook/runner/extract_format_runner.rb +54 -0
- data/lib/mdlogbook/runner/standard_format_runner.rb +105 -0
- data/lib/mdlogbook/runner/visual_format_runner.rb +77 -0
- data/lib/mdlogbook/runner.rb +27 -0
- data/lib/mdlogbook/section_extractor.rb +140 -0
- data/lib/mdlogbook/version.rb +6 -0
- data/lib/mdlogbook/work_thresholds.rb +37 -0
- data/lib/mdlogbook.rb +41 -0
- data/sig/mdlogbook/arg_parser.rbs +54 -0
- data/sig/mdlogbook/brace_expander.rbs +4 -0
- data/sig/mdlogbook/cli.rbs +14 -0
- data/sig/mdlogbook/config.rbs +60 -0
- data/sig/mdlogbook/date_filter.rbs +4 -0
- data/sig/mdlogbook/editor_launcher.rbs +9 -0
- data/sig/mdlogbook/env.rbs +32 -0
- data/sig/mdlogbook/file_resolver.rbs +9 -0
- data/sig/mdlogbook/formatter/anonymize.rbs +4 -0
- data/sig/mdlogbook/formatter/anonymize_dict.rbs +4 -0
- data/sig/mdlogbook/formatter/base.rbs +15 -0
- data/sig/mdlogbook/formatter/calendar.rbs +4 -0
- data/sig/mdlogbook/formatter/chart_series_builder.rbs +9 -0
- data/sig/mdlogbook/formatter/extract.rbs +4 -0
- data/sig/mdlogbook/formatter/grid.rbs +8 -0
- data/sig/mdlogbook/formatter/heatmap.rbs +10 -0
- data/sig/mdlogbook/formatter/line_chart.rbs +7 -0
- data/sig/mdlogbook/formatter/simple.rbs +6 -0
- data/sig/mdlogbook/option_def.rbs +30 -0
- data/sig/mdlogbook/option_registry.rbs +18 -0
- data/sig/mdlogbook/overlap_checker.rbs +13 -0
- data/sig/mdlogbook/parser.rbs +32 -0
- data/sig/mdlogbook/period_spec.rbs +11 -0
- data/sig/mdlogbook/raw_extractor.rbs +15 -0
- data/sig/mdlogbook/runner/base.rbs +4 -0
- data/sig/mdlogbook/runner/editor_runner.rbs +5 -0
- data/sig/mdlogbook/runner/extract_format_runner.rbs +4 -0
- data/sig/mdlogbook/runner/standard_format_runner.rbs +4 -0
- data/sig/mdlogbook/runner/visual_format_runner.rbs +4 -0
- data/sig/mdlogbook/runner.rbs +4 -0
- data/sig/mdlogbook/section_extractor.rbs +24 -0
- data/sig/mdlogbook/work_thresholds.rbs +11 -0
- data/sig/mdlogbook.rbs +8 -0
- metadata +149 -0
data/exe/mlb
ADDED
|
@@ -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
|