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