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
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Mdlogbook
6
+ # Parses markdown logbook files into structured {Result} objects.
7
+ class Parser
8
+ DATE_HEADER_RE = /^# (\d{4}-\d{2}-\d{2})$/
9
+ JOB_HEADER_RE = /^## /
10
+ ENTRY_RE = /^\* ((\d\d):(\d\d)-(\d\d):(\d\d)) \((\d+\.\d+)\)/
11
+
12
+ # Immutable value object representing a single time entry.
13
+ #
14
+ # @!attribute [r] range_str
15
+ # @return [String] Time range string (e.g. "09:00-12:30").
16
+ # @!attribute [r] time
17
+ # @return [Float] Duration in hours calculated from the range.
18
+ # @!attribute [r] job
19
+ # @return [String] Job or task description.
20
+ # @!attribute [r] line_number
21
+ # @return [Integer, nil] 1-based line number in the source file.
22
+ Entry = Data.define(:range_str, :time, :job, :line_number) do
23
+ def initialize(range_str:, time:, job:, line_number: nil)
24
+ super
25
+ end
26
+
27
+ # Returns the start time as minutes since midnight.
28
+ # @return [Integer]
29
+ def start_minutes
30
+ h, m = range_str.split("-").first.split(":").map(&:to_i)
31
+ h * 60 + m
32
+ end
33
+
34
+ # Returns the end time as minutes since midnight (handles midnight crossing).
35
+ # @return [Integer]
36
+ def end_minutes
37
+ parts = range_str.split("-")
38
+ sh, sm = parts[0].split(":").map(&:to_i)
39
+ eh, em = parts[1].split(":").map(&:to_i)
40
+ start_val = sh * 60 + sm
41
+ end_val = eh * 60 + em
42
+ (end_val < start_val) ? end_val + 24 * 60 : end_val
43
+ end
44
+ end
45
+
46
+ # Accumulates parsed entries and warnings for a set of log files.
47
+ class Result
48
+ # @return [Array<String>] Warnings generated during parsing (e.g. time mismatches).
49
+ attr_reader :warnings
50
+
51
+ # Creates an empty result.
52
+ def initialize
53
+ @data = {}
54
+ @warnings = []
55
+ end
56
+
57
+ # Adds a time entry for the given date.
58
+ # @param date [Date] The date of the entry.
59
+ # @param entry [Entry] The entry to add.
60
+ # @return [void]
61
+ def add_entry(date, entry)
62
+ (@data[date] ||= []) << entry
63
+ end
64
+
65
+ # Records a warning message.
66
+ # @param message [String] Warning text.
67
+ # @return [void]
68
+ def add_warning(message)
69
+ @warnings << message
70
+ end
71
+
72
+ # @return [Array<Date>] All dates with entries, sorted ascending.
73
+ def dates
74
+ @data.keys.sort
75
+ end
76
+
77
+ # Returns all entries recorded on the given date.
78
+ # @param date [Date]
79
+ # @return [Array<Entry>]
80
+ def entries_on(date)
81
+ @data.fetch(date, [])
82
+ end
83
+
84
+ # Iterates over each date in ascending order.
85
+ # @yield [date] Each date that has entries.
86
+ # @yieldparam date [Date]
87
+ # @return [void]
88
+ def each_date(&)
89
+ dates.each(&)
90
+ end
91
+
92
+ # Merges another {Result} into this one in place.
93
+ # @param other [Result] The result to merge from.
94
+ # @return [self]
95
+ def merge!(other)
96
+ other.each_date do |date|
97
+ other.entries_on(date).each do |entry|
98
+ add_entry(date, entry)
99
+ end
100
+ end
101
+ @warnings.concat(other.warnings)
102
+ self
103
+ end
104
+ end
105
+
106
+ # @param regexp [Regexp, nil] When set, only entries whose job matches are kept.
107
+ def initialize(regexp: nil)
108
+ @regexp = regexp
109
+ end
110
+
111
+ # Parses the markdown logbook at the given file path.
112
+ # @param filename [String] Path to the logbook file.
113
+ # @return [Result]
114
+ def parse_file(filename)
115
+ File.open(filename, "r") { |io| parse(io) }
116
+ end
117
+
118
+ # Parses logbook content from an IO-like object.
119
+ # @param io [IO] Readable stream of logbook text.
120
+ # @return [Result]
121
+ def parse(io)
122
+ result = Result.new
123
+ date = nil
124
+ job = nil
125
+
126
+ io.each_line.with_index(1) do |raw_line, line_number|
127
+ line = raw_line.chomp
128
+
129
+ if (matched_date = parse_date_header(line))
130
+ date = matched_date
131
+ job = nil
132
+ next
133
+ end
134
+
135
+ if (matched_job = parse_job_header(line))
136
+ job = matched_job
137
+ next
138
+ end
139
+
140
+ parse_entry(line, date, job, line_number, result)
141
+ end
142
+
143
+ result
144
+ end
145
+
146
+ private
147
+
148
+ def parse_date_header(line)
149
+ match = DATE_HEADER_RE.match(line)
150
+ Date.parse(match[1]) if match
151
+ end
152
+
153
+ def parse_job_header(line)
154
+ match = JOB_HEADER_RE.match(line)
155
+ match&.post_match
156
+ end
157
+
158
+ def parse_entry(line, date, job, line_number, result)
159
+ match = ENTRY_RE.match(line)
160
+ return unless match && job
161
+ return if @regexp && !@regexp.match?(job)
162
+
163
+ range_str = match[1]
164
+ calculated = calculate_entry_hours(match)
165
+ add_overnight_notation_warning_if_needed(result, date, match)
166
+ add_warning_if_needed(result, date, range_str, match[6], calculated)
167
+ result.add_entry(date, build_entry(range_str, calculated, job, line_number))
168
+ end
169
+
170
+ def calculate_entry_hours(match)
171
+ h1, m1, h2, m2 = match.values_at(2, 3, 4, 5).map(&:to_i)
172
+ time_diff(h1, m1, h2, m2)
173
+ end
174
+
175
+ def add_warning_if_needed(result, date, range_str, recorded_hours, calculated_hours)
176
+ recorded = format("%.2f", recorded_hours.to_f)
177
+ calculated = format("%.2f", calculated_hours)
178
+ return if recorded == calculated
179
+
180
+ result.add_warning("#{date} #{range_str} #{recorded} != #{calculated}")
181
+ end
182
+
183
+ def add_overnight_notation_warning_if_needed(result, date, match)
184
+ start_hour = match[2].to_i
185
+ end_hour = match[4].to_i
186
+ return unless end_hour < start_hour
187
+ return unless end_hour < 24
188
+
189
+ normalized = normalized_overnight_range(match)
190
+ result.add_warning("#{date} #{match[1]} interpreted as #{normalized}; use 24:xx notation for overnight entries")
191
+ end
192
+
193
+ def build_entry(range_str, calculated_hours, job, line_number)
194
+ Entry.new(range_str: range_str, time: calculated_hours, job: job, line_number: line_number)
195
+ end
196
+
197
+ def normalized_overnight_range(match)
198
+ start_hour = match[2].to_i
199
+ start_minute = match[3]
200
+ end_hour = match[4].to_i + 24
201
+ end_minute = match[5]
202
+ format("%02d:%s-%02d:%s", start_hour, start_minute, end_hour, end_minute)
203
+ end
204
+
205
+ def time_diff(h1, m1, h2, m2)
206
+ t1 = h1 * 60 + m1
207
+ t2 = h2 * 60 + m2
208
+ t2 += 24 * 60 if t2 < t1
209
+ h, m = (t2 - t1).divmod(60)
210
+ h + m.to_f / 60
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Mdlogbook
6
+ # Represents a contiguous date range to be processed.
7
+ #
8
+ # Use {.month} to construct a full-month period, or {.new} for an arbitrary range.
9
+ class PeriodSpec
10
+ # @return [Date]
11
+ attr_reader :start_date, :end_date
12
+
13
+ # @param start_date [Date]
14
+ # @param end_date [Date]
15
+ # @param month_unit [Boolean] true when this period represents exactly one calendar month.
16
+ def initialize(start_date, end_date, month_unit: false, relative: false)
17
+ @start_date = start_date
18
+ @end_date = end_date
19
+ @month_unit = month_unit
20
+ @relative = relative
21
+ end
22
+
23
+ # Constructs a full-month period.
24
+ # @param year [Integer]
25
+ # @param month [Integer]
26
+ # @return [PeriodSpec]
27
+ def self.month(year, month, relative: false)
28
+ first = Date.new(year, month, 1)
29
+ last = first.next_month - 1
30
+ new(first, last, month_unit: true, relative: relative)
31
+ end
32
+
33
+ # @return [Boolean] true when this period represents exactly one calendar month.
34
+ def month_unit?
35
+ @month_unit
36
+ end
37
+
38
+ # @return [Boolean] true when this period was derived from a relative specifier (d/w/m/q/y/D).
39
+ def relative?
40
+ @relative
41
+ end
42
+
43
+ # Returns all (year, month) pairs whose calendar month overlaps this period.
44
+ # @return [Array<Array(Integer, Integer)>]
45
+ def months_covered
46
+ result = []
47
+ d = Date.new(start_date.year, start_date.month, 1)
48
+ last = Date.new(end_date.year, end_date.month, 1)
49
+ while d <= last
50
+ result << [d.year, d.month]
51
+ d = d.next_month
52
+ end
53
+ result
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Mdlogbook
6
+ # Extracts raw markdown lines from logbook files, grouped by date section.
7
+ #
8
+ # Unlike {Parser}, this class preserves all lines (including free-form notes)
9
+ # within each date section, making it suitable for AI summarization.
10
+ class RawExtractor
11
+ # Accumulates raw lines per date for a set of log files.
12
+ class Result
13
+ # Creates an empty result.
14
+ def initialize
15
+ @sections = {}
16
+ end
17
+
18
+ # Returns all dates with content, sorted ascending.
19
+ # @return [Array<Date>]
20
+ def dates
21
+ @sections.keys.sort
22
+ end
23
+
24
+ # Returns raw lines for the given date.
25
+ # @param date [Date]
26
+ # @return [Array<String>]
27
+ def lines_on(date)
28
+ @sections.fetch(date, [])
29
+ end
30
+
31
+ # Iterates over each date in ascending order.
32
+ # @yield [date, lines]
33
+ # @yieldparam date [Date]
34
+ # @yieldparam lines [Array<String>]
35
+ # @return [void]
36
+ def each_date
37
+ dates.each { |date| yield date, lines_on(date) }
38
+ end
39
+
40
+ # Returns true when no dates have been added.
41
+ # @return [Boolean]
42
+ def empty?
43
+ @sections.empty?
44
+ end
45
+
46
+ # Merges another {Result} into this one in place.
47
+ # @param other [Result]
48
+ # @return [self]
49
+ def merge!(other)
50
+ other.each_date { |date, lines| @sections[date] = lines }
51
+ self
52
+ end
53
+
54
+ # @api private
55
+ def add_section(date, lines)
56
+ @sections[date] = lines
57
+ end
58
+ end
59
+
60
+ # Parses logbook content from a file path, extracting sections within +date_range+.
61
+ # @param filename [String]
62
+ # @param date_range [Range<Date>]
63
+ # @return [Result]
64
+ def extract_file(filename, date_range)
65
+ File.open(filename, "r") { |io| extract(io, date_range) }
66
+ end
67
+
68
+ # Parses logbook content from an IO object, extracting sections within +date_range+.
69
+ # @param io [IO]
70
+ # @param date_range [Range<Date>]
71
+ # @return [Result]
72
+ def extract(io, date_range)
73
+ result = Result.new
74
+ current_date = nil
75
+ current_lines = []
76
+
77
+ io.each_line do |line|
78
+ line = line.chomp
79
+ if (m = line.match(/^# (\d{4}-\d{2}-\d{2})$/))
80
+ flush_section(result, current_date, current_lines, date_range)
81
+ current_date = Date.parse(m[1])
82
+ current_lines = [line]
83
+ elsif current_date
84
+ current_lines << line
85
+ end
86
+ end
87
+
88
+ flush_section(result, current_date, current_lines, date_range)
89
+ result
90
+ end
91
+
92
+ private
93
+
94
+ def flush_section(result, date, lines, date_range)
95
+ return unless date && date_range.cover?(date)
96
+
97
+ result.add_section(date, lines.dup)
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mdlogbook
4
+ module Runner
5
+ # Base class for all Runner subclasses.
6
+ #
7
+ # Provides shared functionality: argument parsing, spec iteration,
8
+ # result loading from log files, and file-not-found warnings.
9
+ class Base
10
+ # @param env [Env] Execution context.
11
+ # @param file_resolver [FileResolver] Resolves (year, month) to log file paths.
12
+ def initialize(env:, file_resolver:)
13
+ @env = env
14
+ @file_resolver = file_resolver
15
+ @specs = parse_args
16
+ @work_threshold = nil
17
+ end
18
+
19
+ private
20
+
21
+ def parse_args
22
+ ArgParser.new(today: @env.today, file_resolver: @file_resolver).parse(@env.argv)
23
+ end
24
+
25
+ def each_spec(&)
26
+ @specs.each(&)
27
+ end
28
+
29
+ def each_sorted_spec(&)
30
+ @specs.sort_by(&:start_date).each(&)
31
+ end
32
+
33
+ def load_result(spec)
34
+ parser = Parser.new(regexp: @env.opts.filter_regexp)
35
+ merged = Parser::Result.new
36
+ inject_result(spec, merged) { |path| parser.parse_file(path) }
37
+ DateFilter.filter(merged, spec)
38
+ end
39
+
40
+ def inject_result(spec, merged)
41
+ spec.months_covered.each do |year, month|
42
+ path = @file_resolver.resolve(year, month)
43
+ merge_file_or_warn(merged, path) { yield(path) }
44
+ end
45
+ end
46
+
47
+ def merge_file_or_warn(target, path)
48
+ target.merge!(yield)
49
+ rescue Errno::ENOENT
50
+ report_missing_file(path)
51
+ end
52
+
53
+ def report_missing_file(path)
54
+ @env.puts "Warning: file not found: #{path}" if @env.debug?
55
+ end
56
+
57
+ def spec_range
58
+ [@specs.map(&:start_date).min, @specs.map(&:end_date).max]
59
+ end
60
+
61
+ def work_threshold
62
+ @work_threshold ||= WorkThresholds.new(target_hours: @env.opts.target_hours)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mdlogbook
4
+ module Runner
5
+ # Launches the user's editor for the target log file.
6
+ #
7
+ # After the editor exits successfully, parses the file and reports
8
+ # any parser warnings (time mismatches) and overlapping time entries.
9
+ class EditorRunner < Base
10
+ def run
11
+ if @specs.size > 1
12
+ @env.warn "Warning: --edit uses only the first period; ignoring #{@specs.size - 1} additional period(s)"
13
+ end
14
+ spec = @specs.first
15
+ year, month = spec.months_covered.first
16
+ file = @file_resolver.resolve(year, month)
17
+ ref_date = spec.month_unit? ? @env.today : spec.start_date
18
+ success = EditorLauncher.new(file: file, today: ref_date, args_template: @env.config.editor_args).launch
19
+
20
+ unless success
21
+ @env.warn "Warning: editor exited with an error"
22
+ return
23
+ end
24
+
25
+ validate_file(file)
26
+ end
27
+
28
+ private
29
+
30
+ def validate_file(file)
31
+ result = Parser.new.parse_file(file)
32
+ report_parser_warnings(result)
33
+ report_overlaps(result)
34
+ rescue Errno::ENOENT
35
+ # File does not exist; nothing to validate.
36
+ end
37
+
38
+ def report_parser_warnings(result)
39
+ result.warnings.each { |warning| @env.alert warning }
40
+ end
41
+
42
+ def report_overlaps(result)
43
+ checker = OverlapChecker.new
44
+ overlaps = checker.check(result)
45
+ overlaps.each { |o| @env.alert checker.format_message(o) }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mdlogbook
4
+ module Runner
5
+ # Runs extraction modes: raw markdown output and grep-based section extraction.
6
+ class ExtractFormatRunner < Base
7
+ def run
8
+ if @env.raw_format?
9
+ run_raw
10
+ elsif @env.grep_format?
11
+ run_grep
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def run_raw
18
+ extract_config = @env.config.extract_settings
19
+ extractor = RawExtractor.new
20
+ merged = RawExtractor::Result.new
21
+
22
+ each_spec do |spec|
23
+ inject_result(spec, merged) do |path|
24
+ extractor.extract_file(path, spec.start_date..spec.end_date)
25
+ end
26
+ end
27
+
28
+ range_begin, range_end = spec_range
29
+ Formatter::Extract.new.format_output(merged, @env.stdout,
30
+ extract_config: extract_config,
31
+ range_begin: range_begin,
32
+ range_end: range_end)
33
+ end
34
+
35
+ def run_grep
36
+ extractor = SectionExtractor.new
37
+ merged = SectionExtractor::Result.new
38
+
39
+ each_spec do |spec|
40
+ inject_result(spec, merged) do |path|
41
+ extractor.extract_file(path, spec.start_date..spec.end_date, regexp: @env.opts.regexp)
42
+ end
43
+ end
44
+
45
+ first = true
46
+ merged.each_section do |section|
47
+ @env.puts unless first
48
+ first = false
49
+ section.lines.each { |line| @env.puts line }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mdlogbook
4
+ module Runner
5
+ # Runs standard summary views: simple, calendar, anonymize, anonymize_dict.
6
+ #
7
+ # Handles merge/no-merge logic, overlap checking, and parser warning reporting.
8
+ class StandardFormatRunner < Base
9
+ def run
10
+ formatter = build_formatter
11
+ result = if should_merge?
12
+ run_merge(formatter)
13
+ else
14
+ run_each(formatter)
15
+ end
16
+
17
+ report_parser_warnings(result) if @env.warning_reporting_format?
18
+ report_overlaps(result) if @env.overlap_reporting_format?
19
+ end
20
+
21
+ private
22
+
23
+ def build_formatter
24
+ case @env.opts.format
25
+ when :simple
26
+ Formatter::Simple.new(color: @env.tty?)
27
+ when :calendar
28
+ Formatter::Calendar.new(color: @env.tty?)
29
+ when :anonymize
30
+ Formatter::Anonymize.new(color: false)
31
+ when :anonymize_dict
32
+ Formatter::AnonymizeDict.new(color: false)
33
+ else
34
+ raise ArgumentError, "unknown format: #{@env.opts.format}"
35
+ end
36
+ end
37
+
38
+ # Relative specifiers (d/w/m/y/D) and non-month-unit specs (week, day, duration) merge by default.
39
+ # Relative year (y1) expands to month_unit specs, so relative? handles that case.
40
+ # Explicit --merge or --no-merge overrides this.
41
+ def should_merge?
42
+ return true if @env.opts.merge
43
+ return false if @env.opts.no_merge
44
+
45
+ @specs.any? { |s| s.relative? || !s.month_unit? }
46
+ end
47
+
48
+ def run_each(formatter)
49
+ first = true
50
+ all = Parser::Result.new
51
+ threshold = work_threshold
52
+
53
+ each_sorted_spec do |spec|
54
+ result = load_result(spec)
55
+ next if result.dates.empty?
56
+
57
+ @env.puts unless first
58
+ first = false
59
+ output(formatter, result, threshold, range_begin: spec.start_date, range_end: spec.end_date)
60
+ all.merge!(result)
61
+ end
62
+
63
+ all
64
+ end
65
+
66
+ def run_merge(formatter)
67
+ merged = Parser::Result.new
68
+
69
+ each_spec do |spec|
70
+ merged.merge!(load_result(spec))
71
+ end
72
+
73
+ range_begin, range_end = spec_range
74
+ output(formatter, merged, work_threshold,
75
+ range_begin: range_begin,
76
+ range_end: range_end)
77
+
78
+ merged
79
+ end
80
+
81
+ def output(formatter, result, threshold, range_begin: nil, range_end: nil)
82
+ formatter.format_output(result, threshold, @env.stdout,
83
+ today: @env.today,
84
+ debug: @env.debug?,
85
+ anonymize_settings: @env.anonymize_settings,
86
+ range_begin: range_begin,
87
+ range_end: range_end)
88
+ end
89
+
90
+ def report_overlaps(result)
91
+ checker = OverlapChecker.new
92
+ overlaps = checker.check(result)
93
+ return if overlaps.empty?
94
+
95
+ overlaps.each { |o| @env.alert checker.format_message(o) }
96
+ end
97
+
98
+ def report_parser_warnings(result)
99
+ return if result.warnings.empty?
100
+
101
+ result.warnings.each { |warning| @env.alert warning }
102
+ end
103
+ end
104
+ end
105
+ end