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