howzit 2.1.21 → 2.1.22

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6cac91a8b188ff256fa863ad94e134e820ed2aa2eb766a10fea38ee745de750
4
- data.tar.gz: 90799282be7a348b595df19a5c09850db8ac4d653bd81063d49f1830e6cb0761
3
+ metadata.gz: 4c45e148fc771ca280dbf21202a25f3b6192eb0534f88692103630749de801c9
4
+ data.tar.gz: 2567392e9e6389845be4809aca60ccb347848b2c7575f0c6f4c9232f4a4fe4f0
5
5
  SHA512:
6
- metadata.gz: 6b98ee5eb923878803f0268080ad1d56f4f9aed7279e6dcb245f13659e3a947348212741b3409861aacff145bc5abb19af7dd9810a854626e6b5067fc33ad954
7
- data.tar.gz: 9be180170d36f0f415e3f0977b5b951368f7498fe630cd4f900626bf852c5678b860cea8ac63472dec0c03c41531cf70eb58a5002298a5ed9f5661204aa6d7cb
6
+ metadata.gz: 9e22a71fee828ebfcd96e2f1ea5d4f27ad5e6a0401cc6a73f8344313006a160b10db160c6eb71b7f528df8b23233871a6c914c8ccdb48d57a88b60fb25d4e287
7
+ data.tar.gz: 98411cc9c877f08309c26df629c713510514afeb740a85e741700bd8d9f96b373c5b40affe540e371dd93321aeec6c088fd65d6b953550e4b3161829aa7c0a58
data/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ### 2.1.22
2
+
3
+ 2025-12-13 06:14
4
+
5
+ #### NEW
6
+
7
+ - Template selection menu when creating new build notes
8
+ - Prompt for required template variables during note creation
9
+ - Gum support as fallback for menus and text input
10
+
11
+ #### IMPROVED
12
+
13
+ - Fuzzy matching for template names when fzf unavailable
14
+ - Text input uses Readline for proper line editing (backspace, ctrl-a/e)
15
+
1
16
  ### 2.1.21
2
17
 
3
18
  2025-12-13 05:03
@@ -293,27 +293,37 @@ module Howzit
293
293
  if default
294
294
  input = title
295
295
  else
296
- # title = prompt.ask("{bw}Project name:{x}".c, default: title)
297
- printf "{bw}Project name {xg}[#{title}]{bw}: {x}".c
298
- input = $stdin.gets.chomp
299
- title = input unless input.empty?
296
+ title = Prompt.get_line('{bw}Project name{x}'.c, default: title)
300
297
  end
301
298
  summary = ''
302
299
  unless default
303
- printf '{bw}Project summary: {x}'.c
304
- input = $stdin.gets.chomp
305
- summary = input unless input.empty?
300
+ summary = Prompt.get_line('{bw}Project summary{x}'.c)
301
+ end
302
+
303
+ # Template selection
304
+ selected_templates = []
305
+ template_metadata = {}
306
+ unless default
307
+ selected_templates, template_metadata = select_templates_for_note(title)
306
308
  end
307
309
 
308
310
  fname = 'buildnotes.md'
309
311
  unless default
310
- printf "{bw}Build notes filename (must begin with 'howzit' or 'build')\n{xg}[#{fname}]{bw}: {x}".c
311
- input = $stdin.gets.chomp
312
- fname = input unless input.empty?
312
+ fname = Prompt.get_line("{bw}Build notes filename{x}\n(must begin with 'howzit' or 'build')".c, default: fname)
313
+ end
314
+
315
+ # Build metadata section
316
+ metadata_lines = []
317
+ unless selected_templates.empty?
318
+ metadata_lines << "template: #{selected_templates.join(',')}"
319
+ end
320
+ template_metadata.each do |key, value|
321
+ metadata_lines << "#{key}: #{value}"
313
322
  end
323
+ metadata_section = metadata_lines.empty? ? '' : "#{metadata_lines.join("\n")}\n\n"
314
324
 
315
325
  note = <<~EOBUILDNOTES
316
- # #{title}
326
+ #{metadata_section}# #{title}
317
327
 
318
328
  #{summary}
319
329
 
@@ -369,6 +379,68 @@ module Howzit
369
379
 
370
380
  private
371
381
 
382
+ ##
383
+ ## Select templates for a new build note
384
+ ##
385
+ ## @param project_title [String] The project title for prompts
386
+ ##
387
+ ## @return [Array<Array, Hash>] Array of [selected_template_names, required_vars_hash]
388
+ ##
389
+ def select_templates_for_note(project_title)
390
+ template_dir = Howzit.config.template_folder
391
+ template_glob = File.join(template_dir, '*.md')
392
+ template_files = Dir.glob(template_glob)
393
+
394
+ return [[], {}] if template_files.empty?
395
+
396
+ # Get basenames without extension for menu
397
+ template_names = template_files.map { |f| File.basename(f, '.md') }.sort
398
+
399
+ # Show multi-select menu
400
+ selected = Prompt.choose_templates(template_names, prompt_text: 'Select templates to include')
401
+ return [[], {}] if selected.empty?
402
+
403
+ # Prompt for required variables from each template
404
+ required_vars = {}
405
+ selected.each do |template_name|
406
+ template_path = File.join(template_dir, "#{template_name}.md")
407
+ next unless File.exist?(template_path)
408
+
409
+ vars = parse_template_required_vars(template_path)
410
+ vars.each do |var|
411
+ next if required_vars.key?(var)
412
+
413
+ value = Prompt.get_line("{bw}[#{template_name}] requires {by}#{var}{x}".c)
414
+ required_vars[var] = value unless value.empty?
415
+ end
416
+ end
417
+
418
+ [selected, required_vars]
419
+ end
420
+
421
+ ##
422
+ ## Parse a template file for required variables
423
+ ##
424
+ ## @param template_path [String] Path to the template file
425
+ ##
426
+ ## @return [Array] Array of required variable names
427
+ ##
428
+ def parse_template_required_vars(template_path)
429
+ content = File.read(template_path)
430
+
431
+ # Look for required: in the metadata at the top of the file
432
+ # Metadata is before the first # heading
433
+ meta_section = content.split(/^#/)[0]
434
+ return [] if meta_section.nil? || meta_section.strip.empty?
435
+
436
+ # Find the required: line
437
+ match = meta_section.match(/^required:\s*(.+)$/i)
438
+ return [] unless match
439
+
440
+ # Split by comma and strip whitespace
441
+ match[1].split(',').map(&:strip).reject(&:empty?)
442
+ end
443
+
372
444
  def topic_search_terms_from_cli
373
445
  args = Howzit.cli_args || []
374
446
  raw = args.join(' ').strip
data/lib/howzit/prompt.rb CHANGED
@@ -100,6 +100,10 @@ module Howzit
100
100
  return fzf_result(res)
101
101
  end
102
102
 
103
+ if Util.command_exist?('gum')
104
+ return gum_choose(matches, query: query, multi: true)
105
+ end
106
+
103
107
  tty_menu(matches, query: query)
104
108
  end
105
109
 
@@ -199,6 +203,190 @@ module Howzit
199
203
  end
200
204
  line == '' ? 1 : line.to_i
201
205
  end
206
+
207
+ ##
208
+ ## Multi-select menu for templates
209
+ ##
210
+ ## @param matches [Array] The options list
211
+ ## @param prompt_text [String] The prompt to display
212
+ ##
213
+ ## @return [Array] the selected results (can be empty)
214
+ ##
215
+ def choose_templates(matches, prompt_text: 'Select templates')
216
+ return [] if matches.count.zero?
217
+ return [] unless $stdout.isatty
218
+
219
+ if Util.command_exist?('fzf')
220
+ height = matches.count + 3
221
+ settings = fzf_template_options(height, prompt_text: prompt_text)
222
+
223
+ # Save terminal state before fzf
224
+ tty_state = `stty -g`.chomp
225
+ res = `echo #{Shellwords.escape(matches.join("\n"))} | fzf #{settings.join(' ')}`.strip
226
+ # Restore terminal state after fzf
227
+ system("stty #{tty_state}")
228
+
229
+ return res.empty? ? [] : res.split(/\n/)
230
+ end
231
+
232
+ if Util.command_exist?('gum')
233
+ return gum_choose(matches, prompt: prompt_text, multi: true, required: false)
234
+ end
235
+
236
+ text_template_input(matches)
237
+ end
238
+
239
+ ##
240
+ ## FZF options for template selection
241
+ ##
242
+ def fzf_template_options(height, prompt_text: 'Select templates')
243
+ [
244
+ '-0',
245
+ '-m',
246
+ "--height=#{height}",
247
+ '--header="Tab: add selection, ctrl-a/d: (de)select all, esc: skip, return: confirm"',
248
+ '--bind ctrl-a:select-all,ctrl-d:deselect-all,ctrl-t:toggle-all',
249
+ "--prompt=\"#{prompt_text} > \""
250
+ ]
251
+ end
252
+
253
+ ##
254
+ ## Text-based template input with fuzzy matching
255
+ ##
256
+ ## @param available [Array] Available template names
257
+ ##
258
+ ## @return [Array] Matched template names
259
+ ##
260
+ def text_template_input(available)
261
+ @stty_save = `stty -g`.chomp
262
+
263
+ trap('INT') do
264
+ system('stty', @stty_save)
265
+ exit
266
+ end
267
+
268
+ puts "\n{bw}Available templates:{x} #{available.join(', ')}".c
269
+ printf '{bw}Enter templates to include, comma-separated (return to skip):{x} '.c
270
+ input = Readline.readline('', true).strip
271
+
272
+ return [] if input.empty?
273
+
274
+ fuzzy_match_templates(input, available)
275
+ ensure
276
+ system('stty', @stty_save) if @stty_save
277
+ end
278
+
279
+ ##
280
+ ## Fuzzy match user input against available templates
281
+ ##
282
+ ## @param input [String] Comma-separated user input
283
+ ## @param available [Array] Available template names
284
+ ##
285
+ ## @return [Array] Matched template names
286
+ ##
287
+ def fuzzy_match_templates(input, available)
288
+ terms = input.split(',').map(&:strip).reject(&:empty?)
289
+ matched = []
290
+
291
+ terms.each do |term|
292
+ # Try exact match first (case-insensitive)
293
+ exact = available.find { |t| t.downcase == term.downcase }
294
+ if exact
295
+ matched << exact unless matched.include?(exact)
296
+ next
297
+ end
298
+
299
+ # Try fuzzy match using the same regex approach as topic matching
300
+ rx = term.to_rx
301
+ fuzzy = available.select { |t| t =~ rx }
302
+
303
+ # Prefer matches that start with the term
304
+ if fuzzy.length > 1
305
+ starts_with = fuzzy.select { |t| t.downcase.start_with?(term.downcase) }
306
+ fuzzy = starts_with unless starts_with.empty?
307
+ end
308
+
309
+ fuzzy.each { |t| matched << t unless matched.include?(t) }
310
+ end
311
+
312
+ matched
313
+ end
314
+
315
+ ##
316
+ ## Prompt for a single line of input
317
+ ##
318
+ ## @param prompt_text [String] The prompt to display
319
+ ## @param default [String] Default value if empty
320
+ ##
321
+ ## @return [String] the entered value
322
+ ##
323
+ def get_line(prompt_text, default: nil)
324
+ return (default || '') unless $stdout.isatty
325
+
326
+ if Util.command_exist?('gum')
327
+ result = gum_input(prompt_text, placeholder: default || '')
328
+ return result.empty? && default ? default : result
329
+ end
330
+
331
+ prompt_with_default = default ? "#{prompt_text} [#{default}]: " : "#{prompt_text}: "
332
+ result = Readline.readline(prompt_with_default, true).to_s.strip
333
+ result.empty? && default ? default : result
334
+ end
335
+
336
+ ##
337
+ ## Use gum for single or multi-select menu
338
+ ##
339
+ ## @param matches [Array] The options list
340
+ ## @param prompt [String] The prompt text
341
+ ## @param multi [Boolean] Allow multiple selections
342
+ ## @param required [Boolean] Require at least one selection
343
+ ## @param query [String] The search term for display
344
+ ##
345
+ ## @return [Array] Selected items
346
+ ##
347
+ def gum_choose(matches, prompt: nil, multi: false, required: true, query: nil)
348
+ prompt_text = prompt || (query ? "Select for '#{query}'" : 'Select')
349
+ args = ['gum', 'choose']
350
+ args << '--no-limit' if multi
351
+ args << "--header=#{Shellwords.escape(prompt_text)}"
352
+ args << '--cursor.foreground=6'
353
+ args << '--selected.foreground=2'
354
+
355
+ tty_state = `stty -g`.chomp
356
+ res = `echo #{Shellwords.escape(matches.join("\n"))} | #{args.join(' ')}`.strip
357
+ system("stty #{tty_state}")
358
+
359
+ if res.empty?
360
+ if required
361
+ Howzit.console.info 'Cancelled'
362
+ Process.exit 0
363
+ end
364
+ return []
365
+ end
366
+
367
+ res.split(/\n/)
368
+ end
369
+
370
+ ##
371
+ ## Use gum for text input
372
+ ##
373
+ ## @param prompt_text [String] The prompt to display
374
+ ## @param placeholder [String] Placeholder text
375
+ ##
376
+ ## @return [String] The entered value
377
+ ##
378
+ def gum_input(prompt_text, placeholder: '')
379
+ args = ['gum', 'input']
380
+ args << "--header=#{Shellwords.escape(prompt_text)}"
381
+ args << "--placeholder=#{Shellwords.escape(placeholder)}" unless placeholder.empty?
382
+ args << '--cursor.foreground=6'
383
+
384
+ tty_state = `stty -g`.chomp
385
+ res = `#{args.join(' ')}`.strip
386
+ system("stty #{tty_state}")
387
+
388
+ res
389
+ end
202
390
  end
203
391
  end
204
392
  end
@@ -3,5 +3,5 @@
3
3
  # Primary module for this gem.
4
4
  module Howzit
5
5
  # Current Howzit version.
6
- VERSION = '2.1.21'
6
+ VERSION = '2.1.22'
7
7
  end
@@ -134,4 +134,106 @@ describe Howzit::BuildNote do
134
134
  expect(how.send(:topic_search_terms_from_cli)).to eq(['release, deploy', 'topic balogna'])
135
135
  end
136
136
  end
137
+
138
+ describe "#collect_topic_matches" do
139
+ before do
140
+ Howzit.options[:multiple_matches] = :first
141
+ end
142
+
143
+ it "collects matches for multiple search terms" do
144
+ search_terms = ['topic tropic', 'topic banana']
145
+ output = []
146
+ matches = how.send(:collect_topic_matches, search_terms, output)
147
+ expect(matches.count).to eq 2
148
+ expect(matches.map(&:title)).to include('Topic Tropic', 'Topic Banana')
149
+ end
150
+
151
+ it "prefers exact matches over fuzzy matches" do
152
+ # 'Topic Banana' should exact-match, not fuzzy match to multiple
153
+ search_terms = ['topic banana']
154
+ output = []
155
+ matches = how.send(:collect_topic_matches, search_terms, output)
156
+ expect(matches.count).to eq 1
157
+ expect(matches[0].title).to eq 'Topic Banana'
158
+ end
159
+
160
+ it "falls back to fuzzy match when no exact match" do
161
+ Howzit.options[:matching] = 'fuzzy'
162
+ search_terms = ['trpc'] # fuzzy for 'tropic'
163
+ output = []
164
+ matches = how.send(:collect_topic_matches, search_terms, output)
165
+ expect(matches.count).to eq 1
166
+ expect(matches[0].title).to eq 'Topic Tropic'
167
+ end
168
+
169
+ it "adds error message for unmatched terms" do
170
+ search_terms = ['nonexistent topic xyz']
171
+ output = []
172
+ matches = how.send(:collect_topic_matches, search_terms, output)
173
+ expect(matches.count).to eq 0
174
+ expect(output.join).to match(/no topic match found/i)
175
+ end
176
+
177
+ it "collects multiple topics from comma-separated input" do
178
+ Howzit.cli_args = ['topic tropic,topic banana']
179
+ search_terms = how.send(:topic_search_terms_from_cli)
180
+ output = []
181
+ matches = how.send(:collect_topic_matches, search_terms, output)
182
+ expect(matches.count).to eq 2
183
+ Howzit.cli_args = []
184
+ end
185
+ end
186
+
187
+ describe "#smart_split_topics" do
188
+ it "splits on comma when not part of topic title" do
189
+ result = how.send(:smart_split_topics, 'topic tropic,topic banana')
190
+ expect(result).to eq(['topic tropic', 'topic banana'])
191
+ end
192
+
193
+ it "preserves comma when part of topic title" do
194
+ result = how.send(:smart_split_topics, 'release, deploy,topic banana')
195
+ expect(result).to eq(['release, deploy', 'topic banana'])
196
+ end
197
+
198
+ it "preserves colon when part of topic title" do
199
+ result = how.send(:smart_split_topics, 'git:clean,blog:update post')
200
+ expect(result).to eq(['git:clean', 'blog:update post'])
201
+ end
202
+
203
+ it "handles mixed separators correctly" do
204
+ result = how.send(:smart_split_topics, 'git:clean:topic tropic')
205
+ expect(result).to eq(['git:clean', 'topic tropic'])
206
+ end
207
+ end
208
+
209
+ describe "#parse_template_required_vars" do
210
+ let(:template_with_required) do
211
+ Tempfile.new(['template', '.md']).tap do |f|
212
+ f.write("required: repo_url, author\n\n# Template\n\n## Section")
213
+ f.close
214
+ end
215
+ end
216
+
217
+ let(:template_without_required) do
218
+ Tempfile.new(['template', '.md']).tap do |f|
219
+ f.write("# Template\n\n## Section")
220
+ f.close
221
+ end
222
+ end
223
+
224
+ after do
225
+ template_with_required.unlink
226
+ template_without_required.unlink
227
+ end
228
+
229
+ it "parses required variables from template metadata" do
230
+ vars = how.send(:parse_template_required_vars, template_with_required.path)
231
+ expect(vars).to eq(['repo_url', 'author'])
232
+ end
233
+
234
+ it "returns empty array when no required metadata" do
235
+ vars = how.send(:parse_template_required_vars, template_without_required.path)
236
+ expect(vars).to eq([])
237
+ end
238
+ end
137
239
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: howzit
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.21
4
+ version: 2.1.22
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Terpstra