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,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "unicode_plot"
|
|
4
|
+
|
|
5
|
+
module Mdlogbook
|
|
6
|
+
module Formatter
|
|
7
|
+
# Renders a day-of-week × hour-of-day density plot showing work patterns.
|
|
8
|
+
#
|
|
9
|
+
# Each time entry is sampled at regular intervals to produce scatter points,
|
|
10
|
+
# which are then rendered using {UnicodePlot.densityplot}. The Y axis
|
|
11
|
+
# represents the day of week (1=Mon … 7=Sun) and the X axis represents the
|
|
12
|
+
# hour of day.
|
|
13
|
+
class Heatmap
|
|
14
|
+
# Sample interval in minutes for generating scatter points from entries.
|
|
15
|
+
SAMPLE_INTERVAL = 10
|
|
16
|
+
|
|
17
|
+
# Height is fixed at 7 rows — one per day of the week.
|
|
18
|
+
HEIGHT = 7
|
|
19
|
+
|
|
20
|
+
# Resolution presets: name → chart width (columns).
|
|
21
|
+
RESOLUTIONS = {
|
|
22
|
+
"1h" => 24,
|
|
23
|
+
"30m" => 48,
|
|
24
|
+
"15m" => 96
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# Default resolution when none is specified.
|
|
28
|
+
DEFAULT_RESOLUTION = "30m"
|
|
29
|
+
|
|
30
|
+
# Day-of-week labels for the legend printed below the chart.
|
|
31
|
+
DAY_LABELS = %w[Mon Tue Wed Thu Fri Sat Sun].freeze
|
|
32
|
+
|
|
33
|
+
# Renders the heatmap for the given result.
|
|
34
|
+
#
|
|
35
|
+
# @param result [Parser::Result] Parsed entries (already filtered/merged).
|
|
36
|
+
# @param io [IO] Output stream.
|
|
37
|
+
# @param resolution [String, nil] Time resolution: "15m", "30m" (default), or "1h".
|
|
38
|
+
# @param heatmap_config [Config::HeatmapSettings] Heatmap display settings.
|
|
39
|
+
# @param range_begin [Date, nil] Start date of the displayed period.
|
|
40
|
+
# @param range_end [Date, nil] End date of the displayed period.
|
|
41
|
+
# @return [void]
|
|
42
|
+
def format_output(result, io = $stdout, resolution: nil, heatmap_config: Config::HeatmapSettings.new(start_hour: Config::DEFAULT_HEATMAP_START_HOUR), range_begin: nil, range_end: nil)
|
|
43
|
+
start_hour = heatmap_config.start_hour
|
|
44
|
+
xs, ys = build_points(result, start_hour: start_hour)
|
|
45
|
+
|
|
46
|
+
if xs.empty?
|
|
47
|
+
io.puts "No entries to display."
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
width = resolve_width(resolution)
|
|
52
|
+
plot = UnicodePlot.densityplot(
|
|
53
|
+
xs, ys,
|
|
54
|
+
title: "Work Hours Heatmap",
|
|
55
|
+
xlim: [start_hour, start_hour + 24],
|
|
56
|
+
ylim: [1, 7],
|
|
57
|
+
width: width,
|
|
58
|
+
height: HEIGHT
|
|
59
|
+
)
|
|
60
|
+
DAY_LABELS.each_with_index do |label, i|
|
|
61
|
+
plot.annotate_row!(:r, i, label)
|
|
62
|
+
end
|
|
63
|
+
plot.render(io)
|
|
64
|
+
print_summary(io, result, range_begin, range_end)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Generates (hour, weekday) scatter points from all entries.
|
|
70
|
+
#
|
|
71
|
+
# Each entry is sampled at {SAMPLE_INTERVAL}-minute steps from start to end.
|
|
72
|
+
# The weekday Y value is inverted so Monday=7 (top) and Sunday=1 (bottom).
|
|
73
|
+
# Hours before +start_hour+ are wrapped by adding 24 so they appear on the
|
|
74
|
+
# right side of the chart.
|
|
75
|
+
#
|
|
76
|
+
# @param result [Parser::Result]
|
|
77
|
+
# @param start_hour [Integer] Hour at which the X axis begins.
|
|
78
|
+
# @return [Array(Array<Float>, Array<Float>)] xs (hours) and ys (weekdays).
|
|
79
|
+
def build_points(result, start_hour:)
|
|
80
|
+
xs = []
|
|
81
|
+
ys = []
|
|
82
|
+
|
|
83
|
+
result.each_date do |date|
|
|
84
|
+
iso_wday = (date.wday == 0) ? 7 : date.wday
|
|
85
|
+
wday_y = (8 - iso_wday).to_f
|
|
86
|
+
|
|
87
|
+
result.entries_on(date).each do |entry|
|
|
88
|
+
start_min = entry.start_minutes
|
|
89
|
+
end_min = entry.end_minutes
|
|
90
|
+
min = start_min
|
|
91
|
+
|
|
92
|
+
while min < end_min
|
|
93
|
+
hour = min / 60.0
|
|
94
|
+
hour += 24 if hour < start_hour
|
|
95
|
+
xs << hour
|
|
96
|
+
ys << wday_y
|
|
97
|
+
min += SAMPLE_INTERVAL
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
[xs, ys]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Prints the period and summary statistics.
|
|
106
|
+
# @param io [IO]
|
|
107
|
+
# @param result [Parser::Result]
|
|
108
|
+
# @param range_begin [Date, nil]
|
|
109
|
+
# @param range_end [Date, nil]
|
|
110
|
+
# @return [void]
|
|
111
|
+
def print_summary(io, result, range_begin, range_end)
|
|
112
|
+
total = 0.0
|
|
113
|
+
days_with_entries = 0
|
|
114
|
+
result.each_date do |date|
|
|
115
|
+
daily = result.entries_on(date).sum(&:time)
|
|
116
|
+
next unless daily > 0
|
|
117
|
+
|
|
118
|
+
total += daily
|
|
119
|
+
days_with_entries += 1
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
parts = []
|
|
123
|
+
parts << "#{range_begin}~#{range_end}" if range_begin && range_end
|
|
124
|
+
parts << format("%.1fh total", total)
|
|
125
|
+
parts << format("%.1fh/day avg", total / days_with_entries) if days_with_entries > 0
|
|
126
|
+
io.puts parts.join(" ")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Resolves chart width from the resolution name.
|
|
130
|
+
# @param resolution [String, nil]
|
|
131
|
+
# @return [Integer]
|
|
132
|
+
# @raise [ArgumentError] If the resolution name is unknown.
|
|
133
|
+
def resolve_width(resolution)
|
|
134
|
+
resolution ||= DEFAULT_RESOLUTION
|
|
135
|
+
RESOLUTIONS.fetch(resolution) do
|
|
136
|
+
raise ArgumentError,
|
|
137
|
+
"unknown --heatmap resolution: #{resolution.inspect} (use #{RESOLUTIONS.keys.join(", ")})"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "io/console"
|
|
5
|
+
require "unicode_plot"
|
|
6
|
+
|
|
7
|
+
module Mdlogbook
|
|
8
|
+
module Formatter
|
|
9
|
+
# Renders a cumulative-hours line chart for a single calendar month.
|
|
10
|
+
#
|
|
11
|
+
# Three series are drawn (bottom layer to top layer):
|
|
12
|
+
# * Weekday target — step-wise projection that advances only on working days (Mon–Fri).
|
|
13
|
+
# * Linear target — straight-line projection of the monthly target.
|
|
14
|
+
# * Actual — cumulative hours logged per calendar day (drawn last, highest priority).
|
|
15
|
+
#
|
|
16
|
+
# Below the chart, remaining-hours stats are printed (same format as {Calendar}).
|
|
17
|
+
class LineChart
|
|
18
|
+
include RemainingHelper
|
|
19
|
+
include ChartSeriesBuilder
|
|
20
|
+
|
|
21
|
+
SIZES = {
|
|
22
|
+
"small" => {width: 60, height: 15},
|
|
23
|
+
"medium" => {width: 80, height: 20},
|
|
24
|
+
"large" => {width: 100, height: 25},
|
|
25
|
+
"max" => nil
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
SIZE_ALIASES = {"s" => "small", "m" => "medium", "l" => "large", "x" => "max"}.freeze
|
|
29
|
+
|
|
30
|
+
# Approximate extra terminal columns unicode_plot needs beyond the chart width
|
|
31
|
+
# (axis labels on the left + legend text on the right).
|
|
32
|
+
OVERHEAD = 30
|
|
33
|
+
|
|
34
|
+
# Renders a chart and stats for a single month.
|
|
35
|
+
#
|
|
36
|
+
# @param result [Parser::Result] Entries filtered to the given month.
|
|
37
|
+
# @param settings [WorkThresholds]
|
|
38
|
+
# @param io [IO] Output stream.
|
|
39
|
+
# @param spec [PeriodSpec] The month being visualised (must be month_unit?).
|
|
40
|
+
# @param size [String, nil] Chart size: "small", "medium", "large", "max", or nil (auto).
|
|
41
|
+
# @param today [Date] Reference date for remaining-hours calculation.
|
|
42
|
+
# @param line_chart_config [Config::VisualSettings, nil] Series visibility and color overrides.
|
|
43
|
+
# @return [void]
|
|
44
|
+
def format_output(result, settings, io = $stdout, spec:, size: nil, today: Date.today, line_chart_config: nil, **_options)
|
|
45
|
+
vc = line_chart_config || Config::VisualSettings.new
|
|
46
|
+
first_day = spec.start_date
|
|
47
|
+
last_day = spec.end_date
|
|
48
|
+
n_days = (last_day - first_day).to_i + 1
|
|
49
|
+
|
|
50
|
+
xs = (1..n_days).map(&:to_f)
|
|
51
|
+
|
|
52
|
+
y_actual = build_actual(result, first_day, n_days)
|
|
53
|
+
has_target = settings.target_hours?
|
|
54
|
+
y_linear = has_target ? build_linear_target(settings.target_hours, n_days) : nil
|
|
55
|
+
y_weekday = has_target ? build_weekday_target(settings, first_day, n_days) : nil
|
|
56
|
+
|
|
57
|
+
actual_max = y_actual.max || 0.0
|
|
58
|
+
y_max = has_target ? [settings.target_hours, actual_max].max : actual_max
|
|
59
|
+
|
|
60
|
+
dims = chart_dimensions(size, io)
|
|
61
|
+
title = first_day.strftime("%Y-%m")
|
|
62
|
+
base_opts = {
|
|
63
|
+
title: title, xlabel: "Day", ylabel: "Hours",
|
|
64
|
+
xlim: [1, n_days], ylim: [0, y_max],
|
|
65
|
+
width: dims[:width], height: dims[:height]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Draw series back-to-front: Today (bottom) → Weekday → Linear → Actual (top).
|
|
69
|
+
in_current_month = today.year == first_day.year && today.month == first_day.month
|
|
70
|
+
show_today = vc.show_today && in_current_month
|
|
71
|
+
|
|
72
|
+
# When no target is set, force-hide target lines regardless of visual config.
|
|
73
|
+
effective_vc = if has_target
|
|
74
|
+
vc
|
|
75
|
+
else
|
|
76
|
+
Config::VisualSettings.new(show_today: vc.show_today, show_weekday: false,
|
|
77
|
+
show_linear: false, colors: vc.colors)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The first lineplot call initialises the chart. Use whichever visible series
|
|
81
|
+
# is the bottom-most, or fall back to a transparent "anchor" series.
|
|
82
|
+
first_series, *rest_series = build_series(xs, y_actual, y_linear, y_weekday, y_max,
|
|
83
|
+
today, show_today, effective_vc)
|
|
84
|
+
plot = UnicodePlot.lineplot(first_series[:x], first_series[:y],
|
|
85
|
+
name: first_series[:name], **base_opts, **color_opt(first_series[:name], vc))
|
|
86
|
+
rest_series.each do |s|
|
|
87
|
+
UnicodePlot.lineplot!(plot, s[:x], s[:y], name: s[:name], **color_opt(s[:name], vc))
|
|
88
|
+
end
|
|
89
|
+
plot.render(io)
|
|
90
|
+
|
|
91
|
+
if has_target
|
|
92
|
+
total = y_actual.last || 0.0
|
|
93
|
+
print_remaining(io, total, first_day, settings, today)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Builds the ordered list of series to render (bottom to top).
|
|
100
|
+
# @return [Array<Hash>] Each element has +:name+, +:x+, +:y+.
|
|
101
|
+
def build_series(xs, y_actual, y_linear, y_weekday, y_max, today, show_today, vc)
|
|
102
|
+
series = []
|
|
103
|
+
if show_today
|
|
104
|
+
x = today.day.to_f
|
|
105
|
+
series << {name: "Today", x: [x, x], y: [0.0, y_max.to_f]}
|
|
106
|
+
end
|
|
107
|
+
series << {name: "Weekday target", x: xs, y: y_weekday} if vc.show_weekday
|
|
108
|
+
series << {name: "Linear target", x: xs, y: y_linear} if vc.show_linear
|
|
109
|
+
series << {name: "Actual", x: xs, y: y_actual}
|
|
110
|
+
series
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Returns a color keyword argument hash for the given series name, or {} if not set.
|
|
114
|
+
# @param name [String]
|
|
115
|
+
# @param vc [Config::VisualSettings]
|
|
116
|
+
# @return [Hash]
|
|
117
|
+
def color_opt(name, vc)
|
|
118
|
+
key = name.downcase.split.first # "Actual"→"actual", "Linear target"→"linear", etc.
|
|
119
|
+
color = vc.colors[key]
|
|
120
|
+
color ? {color: color} : {}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Resolves chart dimensions for the given size name (or auto-detects from terminal).
|
|
124
|
+
# @param size [String, nil] Size name or nil for auto-detection.
|
|
125
|
+
# @param io [IO] Output stream used to query terminal width.
|
|
126
|
+
# @return [Hash{width: Integer, height: Integer}]
|
|
127
|
+
def chart_dimensions(size, io)
|
|
128
|
+
size = SIZE_ALIASES.fetch(size, size)
|
|
129
|
+
|
|
130
|
+
if size && size != "max"
|
|
131
|
+
return SIZES.fetch(size) { raise ArgumentError, "unknown --line-chart size: #{size.inspect} (use small/s, medium/m, large/l, or max/x)" }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
term_cols = terminal_width(io)
|
|
135
|
+
|
|
136
|
+
if term_cols
|
|
137
|
+
# Auto or max: fill the terminal width.
|
|
138
|
+
width = [term_cols - OVERHEAD, 60].max
|
|
139
|
+
height = [width / 4, 35].min
|
|
140
|
+
{width: width, height: height}
|
|
141
|
+
elsif size == "max"
|
|
142
|
+
{width: 140, height: 35}
|
|
143
|
+
else
|
|
144
|
+
# Auto but not a TTY: fall back to medium.
|
|
145
|
+
SIZES["medium"]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def terminal_width(io)
|
|
150
|
+
io.winsize[1]
|
|
151
|
+
rescue
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mdlogbook
|
|
4
|
+
module Formatter
|
|
5
|
+
# Formats logbook entries as a flat list with a total summary line.
|
|
6
|
+
class Simple < Base
|
|
7
|
+
ROW_FORMAT = "%s%10s %11s %6.2f %s%s\n"
|
|
8
|
+
|
|
9
|
+
# Writes all entries and a total summary to +io+.
|
|
10
|
+
# @param result [Parser::Result]
|
|
11
|
+
# @param settings [WorkThresholds]
|
|
12
|
+
# @param io [IO] Output stream (default: $stdout).
|
|
13
|
+
# @return [void]
|
|
14
|
+
def format_output(result, settings, io = $stdout, **_options)
|
|
15
|
+
sum = 0.0
|
|
16
|
+
prev_date = nil
|
|
17
|
+
colors = row_colors.dup
|
|
18
|
+
|
|
19
|
+
result.each_date do |date|
|
|
20
|
+
unless date == prev_date
|
|
21
|
+
prev_date = date
|
|
22
|
+
color = colors.first
|
|
23
|
+
colors.rotate!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
result.entries_on(date).sort_by(&:range_str).each do |entry|
|
|
27
|
+
sum += entry.time
|
|
28
|
+
io.printf ROW_FORMAT, color, date, entry.range_str, entry.time, entry.job, color_reset
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
io.puts "-" * 78
|
|
33
|
+
summary = if settings.target_hours?
|
|
34
|
+
Kernel.format("(%.1f day, %.1f%% of target)", sum / 8, sum * 100 / settings.target_hours)
|
|
35
|
+
else
|
|
36
|
+
Kernel.format("(%.1f day)", sum / 8)
|
|
37
|
+
end
|
|
38
|
+
io.printf ROW_FORMAT, "", "", "Total:", sum, summary, ""
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mdlogbook
|
|
4
|
+
# Describes a single CLI option with metadata for OptionParser and shell completion.
|
|
5
|
+
#
|
|
6
|
+
# @!attribute [r] short
|
|
7
|
+
# @return [String, nil] Short option flag (e.g. "-e").
|
|
8
|
+
# @!attribute [r] long
|
|
9
|
+
# @return [String] Long option flag (e.g. "--edit").
|
|
10
|
+
# @!attribute [r] arg_name
|
|
11
|
+
# @return [String, nil] Argument placeholder name (e.g. "SIZE"), nil for flags.
|
|
12
|
+
# @!attribute [r] arg_optional
|
|
13
|
+
# @return [Boolean] Whether the argument is optional (e.g. --line-chart[=SIZE]).
|
|
14
|
+
# @!attribute [r] description
|
|
15
|
+
# @return [String] Help text for the option.
|
|
16
|
+
# @!attribute [r] type
|
|
17
|
+
# @return [Class, nil] Argument type for OptionParser (String, Numeric, Regexp, nil).
|
|
18
|
+
# @!attribute [r] values
|
|
19
|
+
# @return [Array<String>, nil] Allowed values for shell completion.
|
|
20
|
+
# @!attribute [r] completion
|
|
21
|
+
# @return [Symbol, String, nil] Completion type (:file, :directory) or shell command string.
|
|
22
|
+
OptionDef = Data.define(:short, :long, :arg_name, :arg_optional, :description, :type, :values, :completion) do
|
|
23
|
+
# @param long [String] Long option flag (required).
|
|
24
|
+
# @param description [String] Help text (required).
|
|
25
|
+
# @param short [String, nil] Short option flag.
|
|
26
|
+
# @param arg_name [String, nil] Argument placeholder name.
|
|
27
|
+
# @param arg_optional [Boolean] Whether the argument is optional.
|
|
28
|
+
# @param type [Class, nil] Argument type for OptionParser.
|
|
29
|
+
# @param values [Array<String>, nil] Allowed values for shell completion.
|
|
30
|
+
# @param completion [Symbol, String, nil] Completion type or shell command.
|
|
31
|
+
def initialize(long:, description:, short: nil, arg_name: nil, arg_optional: false, type: nil, values: nil,
|
|
32
|
+
completion: nil)
|
|
33
|
+
super(short: short, long: long, arg_name: arg_name, arg_optional: arg_optional, description: description,
|
|
34
|
+
type: type, values: values, completion: completion)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Boolean] Whether this option is a flag (takes no argument).
|
|
38
|
+
def flag?
|
|
39
|
+
arg_name.nil?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [String] The option name without the leading "--".
|
|
43
|
+
def long_name
|
|
44
|
+
long.delete_prefix("--")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Generates the argument list for OptionParser#on.
|
|
48
|
+
#
|
|
49
|
+
# @return [Array] Arguments suitable for OptionParser#on.
|
|
50
|
+
def optparse_args
|
|
51
|
+
args = []
|
|
52
|
+
args << short if short
|
|
53
|
+
args << optparse_long_flag
|
|
54
|
+
args << type if type
|
|
55
|
+
args << description
|
|
56
|
+
args
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# @return [String] Long flag formatted for OptionParser.
|
|
62
|
+
def optparse_long_flag
|
|
63
|
+
return long unless arg_name
|
|
64
|
+
|
|
65
|
+
arg_optional ? "#{long}[=#{arg_name}]" : "#{long}=#{arg_name}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Mdlogbook
|
|
6
|
+
# Collects CLI option definitions and generates OptionParser instances and shell completion scripts.
|
|
7
|
+
class OptionRegistry
|
|
8
|
+
# @return [void]
|
|
9
|
+
def initialize
|
|
10
|
+
@entries = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Adds a separator line for help text grouping.
|
|
14
|
+
#
|
|
15
|
+
# @param text [String] Separator label.
|
|
16
|
+
# @return [void]
|
|
17
|
+
def separator(text)
|
|
18
|
+
@entries << text
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Registers an option definition.
|
|
22
|
+
#
|
|
23
|
+
# @param kwargs [Hash] Keyword arguments forwarded to {OptionDef}.
|
|
24
|
+
# @param action [Proc] Block executed when the option is parsed.
|
|
25
|
+
# @return [OptionDef] The registered definition.
|
|
26
|
+
def option(**kwargs, &action)
|
|
27
|
+
defn = OptionDef.new(**kwargs)
|
|
28
|
+
@entries << [defn, action]
|
|
29
|
+
defn
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns only the {OptionDef} entries (excludes separators).
|
|
33
|
+
#
|
|
34
|
+
# @return [Array<OptionDef>]
|
|
35
|
+
def definitions
|
|
36
|
+
@entries.filter_map { |e| e.is_a?(Array) ? e[0] : nil }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Builds an OptionParser from the registered definitions.
|
|
40
|
+
#
|
|
41
|
+
# @param banner [String] Usage banner text.
|
|
42
|
+
# @return [OptionParser]
|
|
43
|
+
def to_option_parser(banner:)
|
|
44
|
+
op = OptionParser.new
|
|
45
|
+
op.banner = banner
|
|
46
|
+
@entries.each do |entry|
|
|
47
|
+
case entry
|
|
48
|
+
when String
|
|
49
|
+
op.separator ""
|
|
50
|
+
op.separator entry
|
|
51
|
+
when Array
|
|
52
|
+
defn, action = entry
|
|
53
|
+
op.on(*defn.optparse_args, &action)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
op
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Generates a bash completion script.
|
|
60
|
+
#
|
|
61
|
+
# @return [String]
|
|
62
|
+
def completion_bash
|
|
63
|
+
all_flags = definitions.flat_map { |d| [d.short, d.long].compact }
|
|
64
|
+
# Collect all options that have completable values/commands, regardless of arg_optional.
|
|
65
|
+
# bash COMP_WORDBREAKS includes "=" by default, so --opt=<TAB> splits into prev="--opt" cur="",
|
|
66
|
+
# making the prev case the primary completion mechanism for both required and optional args.
|
|
67
|
+
opts_completable = definitions.select { |d| d.arg_name && (d.values || d.completion) }
|
|
68
|
+
|
|
69
|
+
lines = []
|
|
70
|
+
lines << "_mlb() {"
|
|
71
|
+
lines << " local cur prev"
|
|
72
|
+
lines << ' cur="${COMP_WORDS[COMP_CWORD]}"'
|
|
73
|
+
lines << ' prev="${COMP_WORDS[COMP_CWORD-1]}"'
|
|
74
|
+
lines << ""
|
|
75
|
+
|
|
76
|
+
unless opts_completable.empty?
|
|
77
|
+
lines << ' case "$prev" in'
|
|
78
|
+
opts_completable.each do |d|
|
|
79
|
+
# For optional-arg options, exclude short form from prev case.
|
|
80
|
+
# "-l small" is invalid (optional args require "=" like --line-chart=small or -lsmall).
|
|
81
|
+
# Required-arg options like -p/--project accept both "-p foo" and "--project=foo".
|
|
82
|
+
names = if d.arg_optional
|
|
83
|
+
d.long
|
|
84
|
+
else
|
|
85
|
+
[d.short, d.long].compact.join("|")
|
|
86
|
+
end
|
|
87
|
+
lines << " #{names})"
|
|
88
|
+
lines << " #{bash_compgen(d)}"
|
|
89
|
+
lines << " return"
|
|
90
|
+
lines << " ;;"
|
|
91
|
+
end
|
|
92
|
+
lines << " esac"
|
|
93
|
+
lines << ""
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
lines << ' if [[ "$cur" == -* ]]; then'
|
|
97
|
+
lines << " COMPREPLY=( $(compgen -W \"#{all_flags.join(" ")}\" -- \"$cur\") )"
|
|
98
|
+
lines << " fi"
|
|
99
|
+
lines << "}"
|
|
100
|
+
lines << "complete -o default -F _mlb mlb"
|
|
101
|
+
lines.join("\n") + "\n"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Generates a zsh completion script.
|
|
105
|
+
#
|
|
106
|
+
# @return [String]
|
|
107
|
+
def completion_zsh
|
|
108
|
+
lines = []
|
|
109
|
+
lines << "#compdef mlb"
|
|
110
|
+
lines << ""
|
|
111
|
+
lines << "_mlb() {"
|
|
112
|
+
lines << " _arguments -s \\"
|
|
113
|
+
|
|
114
|
+
specs = definitions.flat_map { |d| zsh_specs(d) }
|
|
115
|
+
specs.each_with_index do |spec, i|
|
|
116
|
+
continuation = (i < specs.size - 1) ? " \\" : ""
|
|
117
|
+
lines << " #{spec}#{continuation}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
lines << "}"
|
|
121
|
+
lines << ""
|
|
122
|
+
lines << "_mlb \"$@\""
|
|
123
|
+
lines.join("\n") + "\n"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# @param d [OptionDef]
|
|
129
|
+
# @return [String] compgen expression for bash completion.
|
|
130
|
+
def bash_compgen(d)
|
|
131
|
+
if d.values
|
|
132
|
+
"COMPREPLY=( $(compgen -W \"#{d.values.join(" ")}\" -- \"$cur\") )"
|
|
133
|
+
elsif d.completion == :file
|
|
134
|
+
"COMPREPLY=( $(compgen -f -- \"$cur\") )"
|
|
135
|
+
elsif d.completion == :directory
|
|
136
|
+
"COMPREPLY=( $(compgen -d -- \"$cur\") )"
|
|
137
|
+
elsif d.completion.is_a?(String)
|
|
138
|
+
"COMPREPLY=( $(compgen -W \"#{d.completion}\" -- \"$cur\") )"
|
|
139
|
+
else
|
|
140
|
+
""
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @param d [OptionDef]
|
|
145
|
+
# @return [Array<String>] _arguments spec(s) for zsh completion.
|
|
146
|
+
def zsh_specs(d)
|
|
147
|
+
desc = zsh_escape_desc(d.description)
|
|
148
|
+
|
|
149
|
+
if d.short && d.arg_name && d.arg_optional
|
|
150
|
+
# Split optional-arg options with short aliases into two specs.
|
|
151
|
+
# Short form is a plain flag (-l); long form gets value completion (--line-chart=).
|
|
152
|
+
# This prevents zsh from suggesting values as a separate word after -l.
|
|
153
|
+
exclusion = "'(#{d.short} #{d.long})'"
|
|
154
|
+
arg_part = zsh_arg_part(d)
|
|
155
|
+
[
|
|
156
|
+
"#{exclusion}'#{d.short}[#{desc}]'",
|
|
157
|
+
"#{exclusion}'#{d.long}=[#{desc}]#{arg_part}'"
|
|
158
|
+
]
|
|
159
|
+
elsif d.short
|
|
160
|
+
exclusion = "'(#{d.short} #{d.long})'"
|
|
161
|
+
if d.arg_name
|
|
162
|
+
arg_part = zsh_arg_part(d)
|
|
163
|
+
["#{exclusion}{#{d.short},#{d.long}=}'[#{desc}]#{arg_part}'"]
|
|
164
|
+
else
|
|
165
|
+
["#{exclusion}{#{d.short},#{d.long}}'[#{desc}]'"]
|
|
166
|
+
end
|
|
167
|
+
elsif d.arg_name
|
|
168
|
+
arg_part = zsh_arg_part(d)
|
|
169
|
+
["'#{d.long}=[#{desc}]#{arg_part}'"]
|
|
170
|
+
else
|
|
171
|
+
["'#{d.long}[#{desc}]'"]
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# @param desc [String]
|
|
176
|
+
# @return [String] description with zsh special characters escaped.
|
|
177
|
+
def zsh_escape_desc(desc)
|
|
178
|
+
desc.gsub("'", "'\\\\''").gsub("[", "\\[").gsub("]", "\\]")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @param d [OptionDef]
|
|
182
|
+
# @return [String] argument part of zsh _arguments spec.
|
|
183
|
+
def zsh_arg_part(d)
|
|
184
|
+
colon = d.arg_optional ? "::" : ":"
|
|
185
|
+
action = zsh_action(d)
|
|
186
|
+
"#{colon}#{d.arg_name.downcase}:#{action}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @param d [OptionDef]
|
|
190
|
+
# @return [String] zsh completion action.
|
|
191
|
+
def zsh_action(d)
|
|
192
|
+
if d.values
|
|
193
|
+
"(#{d.values.join(" ")})"
|
|
194
|
+
elsif d.completion == :file
|
|
195
|
+
"_files"
|
|
196
|
+
elsif d.completion == :directory
|
|
197
|
+
"_files -/"
|
|
198
|
+
elsif d.completion.is_a?(String)
|
|
199
|
+
"(#{d.completion})"
|
|
200
|
+
else
|
|
201
|
+
""
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mdlogbook
|
|
4
|
+
# Detects overlapping time entries within a {Parser::Result}.
|
|
5
|
+
#
|
|
6
|
+
# Two entries on the same date overlap when the first entry's end time is
|
|
7
|
+
# strictly after the second entry's start time (adjacent entries do not count).
|
|
8
|
+
class OverlapChecker
|
|
9
|
+
# Immutable value object representing a pair of overlapping entries.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] date
|
|
12
|
+
# @return [Date]
|
|
13
|
+
# @!attribute [r] entry_a
|
|
14
|
+
# @return [Parser::Entry] The earlier-starting entry.
|
|
15
|
+
# @!attribute [r] entry_b
|
|
16
|
+
# @return [Parser::Entry] The entry that overlaps with +entry_a+.
|
|
17
|
+
Overlap = Data.define(:date, :entry_a, :entry_b)
|
|
18
|
+
|
|
19
|
+
# Returns all overlapping pairs found in +result+.
|
|
20
|
+
# @param result [Parser::Result]
|
|
21
|
+
# @return [Array<Overlap>]
|
|
22
|
+
def check(result)
|
|
23
|
+
overlaps = []
|
|
24
|
+
result.each_date do |date|
|
|
25
|
+
sorted = result.entries_on(date).sort_by(&:start_minutes)
|
|
26
|
+
sorted.each_cons(2) do |a, b|
|
|
27
|
+
overlaps << Overlap.new(date: date, entry_a: a, entry_b: b) if a.end_minutes > b.start_minutes
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
overlaps
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Formats an {Overlap} as a human-readable string.
|
|
34
|
+
# @param overlap [Overlap]
|
|
35
|
+
# @return [String]
|
|
36
|
+
def format_message(overlap)
|
|
37
|
+
a = overlap.entry_a
|
|
38
|
+
b = overlap.entry_b
|
|
39
|
+
"#{overlap.date} #{a.range_str} (line #{a.line_number}) overlaps #{b.range_str} (line #{b.line_number})"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|