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,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Mdlogbook
|
|
6
|
+
# Loads per-project configuration from a YAML file.
|
|
7
|
+
#
|
|
8
|
+
# The config file location defaults to $XDG_CONFIG_HOME/mdlogbook/config.yml
|
|
9
|
+
# ($XDG_CONFIG_HOME defaults to ~/.config).
|
|
10
|
+
#
|
|
11
|
+
# Config file format:
|
|
12
|
+
# project_name:
|
|
13
|
+
# path: /path/to/logfiles
|
|
14
|
+
# target_hours: 160
|
|
15
|
+
# default_view: simple # simple, calendar, line_chart, grid, or heatmap
|
|
16
|
+
# anonymize:
|
|
17
|
+
# format: "業務 {hash}"
|
|
18
|
+
# passthrough:
|
|
19
|
+
# - "定例ミーティング"
|
|
20
|
+
# - "/^社内/"
|
|
21
|
+
# preamble: |
|
|
22
|
+
# 以下は{range_begin}〜{range_end}の...
|
|
23
|
+
# postamble: "---"
|
|
24
|
+
# heatmap:
|
|
25
|
+
# start_hour: 4 # X-axis start hour (default: 0)
|
|
26
|
+
class Config
|
|
27
|
+
DEFAULT_PROJECT = "mdlogbook"
|
|
28
|
+
DEFAULT_ANONYMIZE_FORMAT = "{hash}"
|
|
29
|
+
DEFAULT_HEATMAP_START_HOUR = 0
|
|
30
|
+
DEFAULT_VISUAL_SETTINGS = {
|
|
31
|
+
"today" => true,
|
|
32
|
+
"weekday" => false,
|
|
33
|
+
"linear" => true
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# Immutable value object holding anonymize formatter settings.
|
|
37
|
+
#
|
|
38
|
+
# @!attribute [r] project
|
|
39
|
+
# @return [String] Project name used as part of the anonymization hash input.
|
|
40
|
+
# @!attribute [r] format
|
|
41
|
+
# @return [String] Template for anonymized headings. Use +{hash}+ as placeholder.
|
|
42
|
+
# @!attribute [r] salt
|
|
43
|
+
# @return [String] Extra per-project salt mixed into anonymization hashes.
|
|
44
|
+
# @!attribute [r] passthrough
|
|
45
|
+
# @return [Array<String>] Job names (or +/pattern/+ regexps) to output without anonymization.
|
|
46
|
+
# @!attribute [r] preamble
|
|
47
|
+
# @return [String, nil] Text prepended before anonymized output. Supports +{range_begin}+/+{range_end}+.
|
|
48
|
+
# @!attribute [r] postamble
|
|
49
|
+
# @return [String, nil] Text appended after anonymized output. Supports +{range_begin}+/+{range_end}+.
|
|
50
|
+
AnonymizeSettings = Data.define(:project, :format, :salt, :passthrough, :preamble, :postamble)
|
|
51
|
+
|
|
52
|
+
# Immutable value object holding visual formatter settings.
|
|
53
|
+
#
|
|
54
|
+
# @!attribute [r] show_today
|
|
55
|
+
# @return [Boolean] Whether to draw a vertical line at today's position.
|
|
56
|
+
# @!attribute [r] show_weekday
|
|
57
|
+
# @return [Boolean] Whether to draw the weekday-step target series.
|
|
58
|
+
# @!attribute [r] show_linear
|
|
59
|
+
# @return [Boolean] Whether to draw the linear target series.
|
|
60
|
+
# @!attribute [r] colors
|
|
61
|
+
# @return [Hash{String=>Symbol}] Series color overrides keyed by series name
|
|
62
|
+
# (+actual+, +linear+, +weekday+, +today+). Values are color symbols
|
|
63
|
+
# (+:red+, +:green+, +:blue+, +:cyan+, +:magenta+, +:yellow+, +:white+, +:normal+).
|
|
64
|
+
VisualSettings = Data.define(:show_today, :show_weekday, :show_linear, :colors) do
|
|
65
|
+
def initialize(show_today: true, show_weekday: false, show_linear: true, colors: {})
|
|
66
|
+
super
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Immutable value object holding heatmap formatter settings.
|
|
71
|
+
#
|
|
72
|
+
# @!attribute [r] start_hour
|
|
73
|
+
# @return [Integer] Hour at which the X axis begins. Hours before this value
|
|
74
|
+
# are wrapped to +hour + 24+ so the full 24-hour window is
|
|
75
|
+
# +[start_hour, start_hour + 24)+.
|
|
76
|
+
HeatmapSettings = Data.define(:start_hour)
|
|
77
|
+
|
|
78
|
+
# Immutable value object holding extract formatter settings.
|
|
79
|
+
#
|
|
80
|
+
# @!attribute [r] preamble
|
|
81
|
+
# @return [String, nil] Text prepended before extracted output. Supports +{range_begin}+/+{range_end}+.
|
|
82
|
+
# @!attribute [r] postamble
|
|
83
|
+
# @return [String, nil] Text appended after extracted output. Supports +{range_begin}+/+{range_end}+.
|
|
84
|
+
ExtractSettings = Data.define(:preamble, :postamble)
|
|
85
|
+
|
|
86
|
+
# @return [String] Path to the config file in use.
|
|
87
|
+
attr_reader :config_path
|
|
88
|
+
|
|
89
|
+
# @param config_path [String, nil] Override path to config file (mainly for testing).
|
|
90
|
+
# @param project [String, nil] Override project name (takes precedence over MDLOGBOOK_PROJECT env var).
|
|
91
|
+
# @raise [Mdlogbook::Error] If the config file does not exist.
|
|
92
|
+
def initialize(config_path: nil, project: nil)
|
|
93
|
+
@config_path = config_path || default_config_path
|
|
94
|
+
@project_override = project
|
|
95
|
+
@data = load_yaml
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Returns the current project name.
|
|
99
|
+
# Priority: constructor override > MDLOGBOOK_PROJECT env var > default ("mdlogbook").
|
|
100
|
+
# @return [String]
|
|
101
|
+
def current_project
|
|
102
|
+
@project_override || ENV.fetch("MDLOGBOOK_PROJECT", DEFAULT_PROJECT)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns all project names defined in the config file.
|
|
106
|
+
# @return [Array<String>]
|
|
107
|
+
def projects
|
|
108
|
+
@data.keys
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Returns the tilde-expanded basedir for the given project.
|
|
112
|
+
# @param project [String]
|
|
113
|
+
# @return [String]
|
|
114
|
+
# @raise [Mdlogbook::Error] If the project key is not found in the config.
|
|
115
|
+
def basedir(project = current_project)
|
|
116
|
+
path = project_data(project).fetch("path")
|
|
117
|
+
File.expand_path(path)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Returns target_hours for the given project.
|
|
121
|
+
# @param project [String]
|
|
122
|
+
# @return [Numeric]
|
|
123
|
+
# @raise [Mdlogbook::Error] If the project key is not found in the config.
|
|
124
|
+
def target_hours(project = current_project)
|
|
125
|
+
project_data(project)["target_hours"]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Returns the configured default view name, or +nil+ when not set.
|
|
129
|
+
# @param project [String]
|
|
130
|
+
# @return [String, nil] One of "simple", "calendar", "line_chart", "grid", or nil.
|
|
131
|
+
def default_view(project = current_project)
|
|
132
|
+
project_data(project)["default_view"]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Returns extract formatter settings for the given project.
|
|
136
|
+
# @param project [String]
|
|
137
|
+
# @return [ExtractSettings]
|
|
138
|
+
# @raise [Mdlogbook::Error] If the project key is not found in the config.
|
|
139
|
+
def extract_settings(project = current_project)
|
|
140
|
+
ext = project_section(project, "extract")
|
|
141
|
+
ExtractSettings.new(
|
|
142
|
+
preamble: ext["preamble"],
|
|
143
|
+
postamble: ext["postamble"]
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
VALID_VISUAL_COLORS = %i[red green blue cyan magenta yellow white normal].freeze
|
|
148
|
+
|
|
149
|
+
# Returns visual formatter settings for the given project.
|
|
150
|
+
# @param project [String]
|
|
151
|
+
# @return [VisualSettings]
|
|
152
|
+
def visual_settings(project = current_project)
|
|
153
|
+
vis = DEFAULT_VISUAL_SETTINGS.merge(project_section(project, "visual"))
|
|
154
|
+
colors = normalized_visual_colors(vis["colors"])
|
|
155
|
+
VisualSettings.new(
|
|
156
|
+
show_today: vis["today"],
|
|
157
|
+
show_weekday: vis["weekday"],
|
|
158
|
+
show_linear: vis["linear"],
|
|
159
|
+
colors: colors
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Returns heatmap formatter settings for the given project.
|
|
164
|
+
# @param project [String]
|
|
165
|
+
# @return [HeatmapSettings]
|
|
166
|
+
def heatmap_settings(project = current_project)
|
|
167
|
+
hm = project_section(project, "heatmap")
|
|
168
|
+
HeatmapSettings.new(
|
|
169
|
+
start_hour: hm.fetch("start_hour", DEFAULT_HEATMAP_START_HOUR)
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Returns the editor_args template for the given project, or +nil+ if not set.
|
|
174
|
+
# @param project [String]
|
|
175
|
+
# @return [String, nil]
|
|
176
|
+
# @raise [Mdlogbook::Error] If the project key is not found in the config.
|
|
177
|
+
def editor_args(project = current_project)
|
|
178
|
+
project_setting(project, "editor_args")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Returns anonymize formatter settings for the given project.
|
|
182
|
+
# @param project [String]
|
|
183
|
+
# @return [AnonymizeSettings]
|
|
184
|
+
# @raise [Mdlogbook::Error] If the project key is not found in the config.
|
|
185
|
+
def anonymize_settings(project = current_project)
|
|
186
|
+
anon = project_section(project, "anonymize")
|
|
187
|
+
AnonymizeSettings.new(
|
|
188
|
+
project: project,
|
|
189
|
+
format: anon.fetch("format", DEFAULT_ANONYMIZE_FORMAT),
|
|
190
|
+
salt: anon.fetch("salt", ""),
|
|
191
|
+
passthrough: anon.fetch("passthrough", []),
|
|
192
|
+
preamble: anon["preamble"],
|
|
193
|
+
postamble: anon["postamble"]
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Returns the default config file path.
|
|
198
|
+
#
|
|
199
|
+
# Priority: +MDLOGBOOK_CONFIG+ env var > +XDG_CONFIG_HOME+/mdlogbook/config.yml.
|
|
200
|
+
# @return [String]
|
|
201
|
+
def self.default_config_path
|
|
202
|
+
if (env_path = ENV["MDLOGBOOK_CONFIG"])
|
|
203
|
+
return File.expand_path(env_path)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
xdg_config_home = ENV.fetch("XDG_CONFIG_HOME", "~/.config")
|
|
207
|
+
File.expand_path(File.join(xdg_config_home, "mdlogbook", "config.yml"))
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def default_config_path
|
|
213
|
+
self.class.default_config_path
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def project_setting(project, key, default = nil)
|
|
217
|
+
project_data(project).fetch(key, default)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def project_section(project, key)
|
|
221
|
+
project_setting(project, key, {}) || {}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def normalized_visual_colors(colors_config)
|
|
225
|
+
(colors_config || {}).filter_map do |key, val|
|
|
226
|
+
color = val&.to_sym
|
|
227
|
+
next unless VALID_VISUAL_COLORS.include?(color)
|
|
228
|
+
|
|
229
|
+
[key.to_s, color]
|
|
230
|
+
end.to_h
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def project_data(project)
|
|
234
|
+
@data.fetch(project) do
|
|
235
|
+
raise Mdlogbook::Error, "project '#{project}' not found in #{@config_path}"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def load_yaml
|
|
240
|
+
unless File.exist?(@config_path)
|
|
241
|
+
raise Mdlogbook::Error, "config file not found: #{@config_path}\nRun `mlb --init` to create one."
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
YAML.safe_load_file(@config_path, aliases: true) || {}
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mdlogbook
|
|
4
|
+
# Filters a {Parser::Result} to only include entries within a given {PeriodSpec}.
|
|
5
|
+
module DateFilter
|
|
6
|
+
# Returns a new {Parser::Result} containing only entries whose date falls within +spec+.
|
|
7
|
+
# @param result [Parser::Result]
|
|
8
|
+
# @param spec [PeriodSpec]
|
|
9
|
+
# @return [Parser::Result]
|
|
10
|
+
def self.filter(result, spec)
|
|
11
|
+
range = spec.start_date..spec.end_date
|
|
12
|
+
filtered = Parser::Result.new
|
|
13
|
+
result.each_date do |date|
|
|
14
|
+
next unless range.cover?(date)
|
|
15
|
+
|
|
16
|
+
result.entries_on(date).each { |entry| filtered.add_entry(date, entry) }
|
|
17
|
+
end
|
|
18
|
+
result.warnings.each { |w| filtered.add_warning(w) }
|
|
19
|
+
filtered
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Mdlogbook
|
|
6
|
+
# Builds and runs an editor command for a logbook file.
|
|
7
|
+
#
|
|
8
|
+
# The editor is determined by the +VISUAL+ environment variable, falling back
|
|
9
|
+
# to +EDITOR+. Additional arguments and cursor positioning are controlled by
|
|
10
|
+
# the +args_template+ parameter (set via +editor_args+ in the config file),
|
|
11
|
+
# which may contain the following placeholders:
|
|
12
|
+
#
|
|
13
|
+
# {file} absolute path to the logbook file
|
|
14
|
+
# {line} line number of the target date's heading, or the nearest
|
|
15
|
+
# insertion point when the exact date is not found
|
|
16
|
+
# {date} today's date in YYYY-MM-DD format
|
|
17
|
+
class EditorLauncher
|
|
18
|
+
# Raised when neither VISUAL nor EDITOR is set.
|
|
19
|
+
class NoEditorError < Mdlogbook::Error; end
|
|
20
|
+
|
|
21
|
+
# @param file [String] Path to the logbook file to open.
|
|
22
|
+
# @param today [Date] Reference date for {line} and {date} placeholders.
|
|
23
|
+
# @param args_template [String, nil] Argument template from config (e.g. "+{line} {file}").
|
|
24
|
+
def initialize(file:, today: Date.today, args_template: nil)
|
|
25
|
+
@file = file
|
|
26
|
+
@today = today
|
|
27
|
+
@args_template = args_template
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Builds the command array suitable for +Kernel.exec+ or +system+.
|
|
31
|
+
# @return [Array<String>]
|
|
32
|
+
# @raise [NoEditorError] when no editor is configured.
|
|
33
|
+
def build_command
|
|
34
|
+
editor = ENV.fetch("VISUAL", nil) || ENV.fetch("EDITOR", nil)
|
|
35
|
+
raise NoEditorError, "No editor configured. Set VISUAL or EDITOR." unless editor
|
|
36
|
+
|
|
37
|
+
return [editor, @file] unless @args_template
|
|
38
|
+
|
|
39
|
+
expanded = expand_template(@args_template)
|
|
40
|
+
[editor] + expanded.shellsplit
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Launches the editor and waits for it to finish.
|
|
44
|
+
# @return [Boolean] true when the editor exited successfully.
|
|
45
|
+
def launch
|
|
46
|
+
system(*build_command)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def expand_template(template)
|
|
52
|
+
template
|
|
53
|
+
.gsub("{file}", @file)
|
|
54
|
+
.gsub("{date}", @today.strftime("%Y-%m-%d"))
|
|
55
|
+
.gsub("{line}", find_line_number.to_s)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Finds the best line number for the target date.
|
|
59
|
+
#
|
|
60
|
+
# Scans all +# YYYY-MM-DD+ headings in the file and returns:
|
|
61
|
+
# - the heading line on exact match
|
|
62
|
+
# - the line just before the next heading when the date falls between two headings
|
|
63
|
+
# - the last line when the date is after all headings
|
|
64
|
+
# - 1 when the date is before all headings, or no headings exist
|
|
65
|
+
#
|
|
66
|
+
# @return [Integer] 1-based line number
|
|
67
|
+
def find_line_number
|
|
68
|
+
lines = File.readlines(@file)
|
|
69
|
+
return 1 if lines.empty?
|
|
70
|
+
|
|
71
|
+
target = @today.strftime("%Y-%m-%d")
|
|
72
|
+
found_any = false
|
|
73
|
+
|
|
74
|
+
lines.each_with_index do |line, idx|
|
|
75
|
+
stripped = line.chomp
|
|
76
|
+
next unless stripped.match?(/\A# \d{4}-\d{2}-\d{2}\z/)
|
|
77
|
+
|
|
78
|
+
date_str = stripped.delete_prefix("# ")
|
|
79
|
+
found_any = true
|
|
80
|
+
return idx + 1 if date_str == target
|
|
81
|
+
return [idx, 1].max if target < date_str
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# After all headings → last line; no headings → 1
|
|
85
|
+
found_any ? lines.length : 1
|
|
86
|
+
rescue Errno::ENOENT
|
|
87
|
+
1
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mdlogbook
|
|
4
|
+
# Immutable execution context shared by Runner and future Executor layers.
|
|
5
|
+
#
|
|
6
|
+
# Bundles the parsed options, remaining arguments, I/O streams, reference date,
|
|
7
|
+
# and loaded configuration into a single value object. Constructed by {CLI}
|
|
8
|
+
# after option parsing and config loading, then passed into {Runner}.
|
|
9
|
+
#
|
|
10
|
+
# @!attribute [r] opts
|
|
11
|
+
# @return [CLI::Options] Parsed command-line options.
|
|
12
|
+
# @!attribute [r] argv
|
|
13
|
+
# @return [Array<String>] Remaining non-option arguments (period specifiers).
|
|
14
|
+
# @!attribute [r] stdout
|
|
15
|
+
# @return [IO] Standard output stream.
|
|
16
|
+
# @!attribute [r] stderr
|
|
17
|
+
# @return [IO] Standard error stream.
|
|
18
|
+
# @!attribute [r] today
|
|
19
|
+
# @return [Date] Reference date for resolving relative period specifiers.
|
|
20
|
+
# @!attribute [r] config
|
|
21
|
+
# @return [Config] Loaded configuration.
|
|
22
|
+
Env = Data.define(:opts, :argv, :stdout, :stderr, :today, :config) do
|
|
23
|
+
# @!group Format query methods
|
|
24
|
+
|
|
25
|
+
# @return [Boolean] true when the output format is +:raw+.
|
|
26
|
+
def raw_format? = opts.format == :raw
|
|
27
|
+
|
|
28
|
+
# @return [Boolean] true when the output format is +:grep+.
|
|
29
|
+
def grep_format? = opts.format == :grep
|
|
30
|
+
|
|
31
|
+
# @return [Boolean] true when the output format is +:line_chart+.
|
|
32
|
+
def line_chart_format? = opts.format == :line_chart
|
|
33
|
+
|
|
34
|
+
# @return [Boolean] true when the output format is +:grid+.
|
|
35
|
+
def grid_format? = opts.format == :grid
|
|
36
|
+
|
|
37
|
+
# @return [Boolean] true when the output format is +:heatmap+.
|
|
38
|
+
def heatmap_format? = opts.format == :heatmap
|
|
39
|
+
|
|
40
|
+
# @return [Boolean] true when the output format supports overlap reporting.
|
|
41
|
+
def overlap_reporting_format? = %i[simple calendar].include?(opts.format)
|
|
42
|
+
|
|
43
|
+
# @return [Boolean] true when the output format supports parser warning reporting.
|
|
44
|
+
def warning_reporting_format? = %i[simple calendar].include?(opts.format)
|
|
45
|
+
|
|
46
|
+
# @return [Boolean] true when the output format is +:anonymize+ or +:anonymize_dict+.
|
|
47
|
+
def anonymize_format? = %i[anonymize anonymize_dict].include?(opts.format)
|
|
48
|
+
|
|
49
|
+
# @return [Boolean] true when the output format is +:raw+ or +:grep+.
|
|
50
|
+
def extract_format? = raw_format? || grep_format?
|
|
51
|
+
|
|
52
|
+
# @return [Boolean] true when the output format is +:line_chart+, +:grid+, or +:heatmap+.
|
|
53
|
+
def visual_format? = line_chart_format? || grid_format? || heatmap_format?
|
|
54
|
+
|
|
55
|
+
# @!endgroup
|
|
56
|
+
|
|
57
|
+
# Returns anonymize settings from config when an anonymize format is active.
|
|
58
|
+
# @return [Hash, nil] Anonymize settings hash, or nil for non-anonymize formats.
|
|
59
|
+
def anonymize_settings = anonymize_format? ? config.anonymize_settings : nil
|
|
60
|
+
|
|
61
|
+
# @return [Boolean] true when the +--edit+ flag is set.
|
|
62
|
+
def edit? = opts.edit
|
|
63
|
+
|
|
64
|
+
# @return [Boolean] true when the +--debug+ flag is set.
|
|
65
|
+
def debug? = opts.debug
|
|
66
|
+
|
|
67
|
+
# @return [Boolean] true when stdout is a TTY.
|
|
68
|
+
def tty? = stdout.respond_to?(:tty?) && stdout.tty?
|
|
69
|
+
|
|
70
|
+
# Writes to stdout.
|
|
71
|
+
# @param args [Array] Arguments forwarded to +IO#puts+.
|
|
72
|
+
# @return [void]
|
|
73
|
+
def puts(*args) = stdout.puts(*args)
|
|
74
|
+
|
|
75
|
+
# Writes to stderr.
|
|
76
|
+
# @param args [Array] Arguments forwarded to +IO#puts+.
|
|
77
|
+
# @return [void]
|
|
78
|
+
def warn(*args) = stderr.puts(*args)
|
|
79
|
+
|
|
80
|
+
# Writes to stderr, highlighted in red when the output is a TTY.
|
|
81
|
+
#
|
|
82
|
+
# Use for warnings that indicate a problem the user may need to fix
|
|
83
|
+
# (e.g. time mismatches, overlapping entries). For informational
|
|
84
|
+
# messages that require no action, use {#warn} instead.
|
|
85
|
+
# @param args [Array] Arguments forwarded to +IO#puts+, wrapped in red on TTY.
|
|
86
|
+
# @return [void]
|
|
87
|
+
def alert(*args)
|
|
88
|
+
if tty?
|
|
89
|
+
args.each { |msg| stderr.puts("\e[31m#{msg}\e[0m") }
|
|
90
|
+
else
|
|
91
|
+
stderr.puts(*args)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mdlogbook
|
|
4
|
+
# Resolves logbook file paths from config and a year/month pair.
|
|
5
|
+
#
|
|
6
|
+
# File paths follow the pattern:
|
|
7
|
+
# $path/$project-YYYY-MM.md
|
|
8
|
+
#
|
|
9
|
+
# where $path comes from the project config and $project is the
|
|
10
|
+
# MDLOGBOOK_PROJECT environment variable (default: "mdlogbook").
|
|
11
|
+
class FileResolver
|
|
12
|
+
# @return [String] Resolved base directory path (tilde expanded).
|
|
13
|
+
attr_reader :basedir
|
|
14
|
+
|
|
15
|
+
# @return [String] Project name used as the filename prefix.
|
|
16
|
+
attr_reader :project
|
|
17
|
+
|
|
18
|
+
# @param config [Config] Configuration object.
|
|
19
|
+
# @param basedir [String, nil] Override base directory (takes precedence over config).
|
|
20
|
+
def initialize(config: Config.new, basedir: nil)
|
|
21
|
+
@basedir = basedir ? File.expand_path(basedir) : config.basedir
|
|
22
|
+
@project = config.current_project
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns the file path for the given year and month.
|
|
26
|
+
# @param year [Integer]
|
|
27
|
+
# @param month [Integer]
|
|
28
|
+
# @return [String]
|
|
29
|
+
def resolve(year, month)
|
|
30
|
+
filename = "#{@project}-#{year}-#{month.to_s.rjust(2, "0")}.md"
|
|
31
|
+
File.join(@basedir, filename)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns all (year, month) pairs that have an existing logfile, sorted ascending.
|
|
35
|
+
# @return [Array<Array(Integer, Integer)>]
|
|
36
|
+
def list_available_months
|
|
37
|
+
re = /#{Regexp.escape(@project)}-(\d{4})-(\d{2})\.md\z/
|
|
38
|
+
Dir.glob(File.join(@basedir, "#{@project}-[0-9][0-9][0-9][0-9]-[0-9][0-9].md"))
|
|
39
|
+
.filter_map { |path| (m = re.match(path)) && [m[1].to_i, m[2].to_i] }
|
|
40
|
+
.sort
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Mdlogbook
|
|
6
|
+
module Formatter
|
|
7
|
+
# Formats logbook entries as anonymized markdown for AI analysis.
|
|
8
|
+
#
|
|
9
|
+
# Outputs only date headers, anonymized (or passthrough) job headers,
|
|
10
|
+
# and time entry lines. All other content is suppressed.
|
|
11
|
+
class Anonymize < Base
|
|
12
|
+
include PassthroughHelper
|
|
13
|
+
|
|
14
|
+
# Writes anonymized output for the given result.
|
|
15
|
+
#
|
|
16
|
+
# Job names are replaced with an 8-character SHA-256 hex prefix unless
|
|
17
|
+
# they match a passthrough rule. Preamble and postamble templates support
|
|
18
|
+
# +{range_begin}+ and +{range_end}+ placeholders.
|
|
19
|
+
#
|
|
20
|
+
# @param result [Parser::Result]
|
|
21
|
+
# @param settings [WorkThresholds]
|
|
22
|
+
# @param io [IO] Output stream (default: $stdout).
|
|
23
|
+
# @param anonymize_settings [Config::AnonymizeSettings, nil]
|
|
24
|
+
# @param range_begin [Date, nil] Start of the output period for template substitution.
|
|
25
|
+
# @param range_end [Date, nil] End of the output period for template substitution.
|
|
26
|
+
# @return [void]
|
|
27
|
+
def format_output(result, _settings, io = $stdout, anonymize_settings: nil, range_begin: nil, range_end: nil, **_options)
|
|
28
|
+
settings = anonymize_settings || Config::AnonymizeSettings.new(project: "", format: "{hash}", salt: "", passthrough: [], preamble: nil, postamble: nil)
|
|
29
|
+
|
|
30
|
+
if settings.preamble
|
|
31
|
+
io.print expand_range(settings.preamble, range_begin, range_end)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
prev_date = nil
|
|
35
|
+
result.each_date do |date|
|
|
36
|
+
io.puts if prev_date
|
|
37
|
+
io.puts "# #{date}"
|
|
38
|
+
prev_date = date
|
|
39
|
+
|
|
40
|
+
emit_entries(io, result.entries_on(date).sort_by(&:range_str), settings)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if settings.postamble
|
|
44
|
+
io.puts
|
|
45
|
+
io.print expand_range(settings.postamble, range_begin, range_end)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def emit_entries(io, entries, settings)
|
|
52
|
+
prev_job = nil
|
|
53
|
+
entries.each do |entry|
|
|
54
|
+
if entry.job != prev_job
|
|
55
|
+
io.puts
|
|
56
|
+
io.puts "## #{heading_for(entry.job, settings)}"
|
|
57
|
+
prev_job = entry.job
|
|
58
|
+
end
|
|
59
|
+
io.puts "* #{entry.range_str} (#{format("%.2f", entry.time)})"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Returns the heading text for a job: passthrough value or anonymized hash.
|
|
64
|
+
# @param job [String]
|
|
65
|
+
# @param settings [Config::AnonymizeSettings]
|
|
66
|
+
# @return [String]
|
|
67
|
+
def heading_for(job, settings)
|
|
68
|
+
return job if passthrough?(job, settings.passthrough)
|
|
69
|
+
|
|
70
|
+
hash = Digest::SHA256.hexdigest("#{settings.project}\0#{settings.salt}\0#{job}")[0, 8]
|
|
71
|
+
settings.format.gsub("{hash}", hash)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Mdlogbook
|
|
6
|
+
module Formatter
|
|
7
|
+
# Outputs a reverse-lookup dictionary for anonymized job names.
|
|
8
|
+
#
|
|
9
|
+
# Each line contains the 8-character SHA-256 hash and the original job name,
|
|
10
|
+
# separated by a space. Passthrough jobs are excluded. Entries are sorted
|
|
11
|
+
# by job name.
|
|
12
|
+
class AnonymizeDict < Base
|
|
13
|
+
include PassthroughHelper
|
|
14
|
+
|
|
15
|
+
# Writes the dictionary to +io+.
|
|
16
|
+
#
|
|
17
|
+
# @param result [Parser::Result]
|
|
18
|
+
# @param settings [WorkThresholds]
|
|
19
|
+
# @param io [IO] Output stream (default: $stdout).
|
|
20
|
+
# @param anonymize_settings [Config::AnonymizeSettings, nil]
|
|
21
|
+
# @return [void]
|
|
22
|
+
def format_output(result, _work_thresholds, io = $stdout, anonymize_settings: nil, **_options)
|
|
23
|
+
settings = anonymize_settings || Config::AnonymizeSettings.new(project: "", format: "{hash}", salt: "", passthrough: [], preamble: nil, postamble: nil)
|
|
24
|
+
|
|
25
|
+
jobs = result.each_date.flat_map { |date| result.entries_on(date).map(&:job) }.uniq.sort
|
|
26
|
+
|
|
27
|
+
jobs.each do |job|
|
|
28
|
+
next if passthrough?(job, settings.passthrough)
|
|
29
|
+
|
|
30
|
+
hash = Digest::SHA256.hexdigest("#{settings.project}\0#{settings.salt}\0#{job}")[0, 8]
|
|
31
|
+
io.puts "#{hash} #{job}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|