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