hiiro 0.1.80 → 0.1.81
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 +4 -4
- data/bin/h-buffer +21 -0
- data/bin/h-pane +21 -0
- data/bin/h-pr +339 -0
- data/bin/h-project +26 -0
- data/bin/h-remind +614 -0
- data/bin/h-sha +49 -2
- data/bin/h-window +21 -0
- data/bin/h-wtree +22 -0
- data/lib/hiiro/version.rb +1 -1
- metadata +4 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3f87285ddecfdb583f432a5017f6261299b44090af65809fd4a8840f7c497a0a
|
|
4
|
+
data.tar.gz: 41376a591fe399529e5eb4d2dd33cd01c55eb9f1a9f9f5cc1a56691608e1a631
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f055d30b6555898fee3011e99ed35085c0989bfb786478815c7e42ece61eba17d7e4c5462b788ca220fa1b06432ebfe17f09cf5d4a84143b8f55b0e0d31da782
|
|
7
|
+
data.tar.gz: 40f214d34537a625a9ecea49d9ba57d9c96cf2e6a85c586b9854eabc18960d2b14009743458008c4127b822dc5c4e630db293fc55b41b0384586702ff0398029
|
data/bin/h-buffer
CHANGED
|
@@ -98,4 +98,25 @@ o.add_subcmd(:clear) { |*args|
|
|
|
98
98
|
puts "Cleared #{buffers.count} buffers"
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
o.add_subcmd(:select) do |*args|
|
|
102
|
+
# Get buffer list with content preview
|
|
103
|
+
output = `tmux list-buffers -F '\#{buffer_name}: \#{buffer_sample}'`.strip
|
|
104
|
+
|
|
105
|
+
if output.empty?
|
|
106
|
+
STDERR.puts "No buffers found"
|
|
107
|
+
next
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
lines = output.split("\n").each_with_object({}) do |line, h|
|
|
111
|
+
buffer_name = line.split(':').first
|
|
112
|
+
h[line] = buffer_name
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
selected = Hiiro::Sk.map_select(lines)
|
|
116
|
+
|
|
117
|
+
if selected
|
|
118
|
+
print selected
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
101
122
|
o.run
|
data/bin/h-pane
CHANGED
|
@@ -71,4 +71,25 @@ o.add_subcmd(:history) { |pane_id=nil|
|
|
|
71
71
|
end
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
o.add_subcmd(:select) do |*args|
|
|
75
|
+
# Get all panes with details
|
|
76
|
+
output = `tmux list-panes -a -F '\#{session_name}:\#{window_index}.\#{pane_index} [\#{pane_current_command}] \#{pane_current_path}'`.strip
|
|
77
|
+
|
|
78
|
+
if output.empty?
|
|
79
|
+
STDERR.puts "No panes found"
|
|
80
|
+
next
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
lines = output.split("\n").each_with_object({}) do |line, h|
|
|
84
|
+
pane_id = line.split.first
|
|
85
|
+
h[line] = pane_id
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
selected = Hiiro::Sk.map_select(lines)
|
|
89
|
+
|
|
90
|
+
if selected
|
|
91
|
+
print selected
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
74
95
|
o.run
|
data/bin/h-pr
CHANGED
|
@@ -244,6 +244,130 @@ class PRManager
|
|
|
244
244
|
end
|
|
245
245
|
end
|
|
246
246
|
|
|
247
|
+
class PinnedPRManager
|
|
248
|
+
PINNED_FILE = File.join(Dir.home, '.config/hiiro/pinned_prs.yml')
|
|
249
|
+
|
|
250
|
+
def initialize
|
|
251
|
+
ensure_file
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def ensure_file
|
|
255
|
+
dir = File.dirname(PINNED_FILE)
|
|
256
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
257
|
+
File.write(PINNED_FILE, [].to_yaml) unless File.exist?(PINNED_FILE)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def load_pinned
|
|
261
|
+
ensure_file
|
|
262
|
+
YAML.load_file(PINNED_FILE) || []
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def save_pinned(prs)
|
|
266
|
+
ensure_file
|
|
267
|
+
File.write(PINNED_FILE, prs.to_yaml)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def pin(pr_info)
|
|
271
|
+
pinned = load_pinned
|
|
272
|
+
existing = pinned.find { |p| p['number'] == pr_info['number'] }
|
|
273
|
+
|
|
274
|
+
if existing
|
|
275
|
+
existing.merge!(pr_info)
|
|
276
|
+
existing['updated_at'] = Time.now.iso8601
|
|
277
|
+
else
|
|
278
|
+
pr_info['pinned_at'] = Time.now.iso8601
|
|
279
|
+
pinned << pr_info
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
save_pinned(pinned)
|
|
283
|
+
pr_info
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def unpin(pr_number)
|
|
287
|
+
pinned = load_pinned
|
|
288
|
+
removed = pinned.reject! { |p| p['number'].to_s == pr_number.to_s }
|
|
289
|
+
save_pinned(pinned)
|
|
290
|
+
removed
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def pinned?(pr_number)
|
|
294
|
+
load_pinned.any? { |p| p['number'].to_s == pr_number.to_s }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def fetch_pr_info(pr_number)
|
|
298
|
+
fields = 'number,title,url,headRefName,state,statusCheckRollup'
|
|
299
|
+
output = `gh pr view #{pr_number} --json #{fields} 2>/dev/null`.strip
|
|
300
|
+
return nil if output.empty?
|
|
301
|
+
JSON.parse(output)
|
|
302
|
+
rescue JSON::ParserError
|
|
303
|
+
nil
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def fetch_current_branch_pr
|
|
307
|
+
fields = 'number,title,url,headRefName,state'
|
|
308
|
+
output = `gh pr view --json #{fields} 2>/dev/null`.strip
|
|
309
|
+
return nil if output.empty?
|
|
310
|
+
JSON.parse(output)
|
|
311
|
+
rescue JSON::ParserError
|
|
312
|
+
nil
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def fetch_my_prs
|
|
316
|
+
output = `gh pr list --author @me --state open --json number,title,headRefName,url 2>/dev/null`.strip
|
|
317
|
+
return [] if output.empty?
|
|
318
|
+
JSON.parse(output) rescue []
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def refresh_status(pr)
|
|
322
|
+
info = fetch_pr_info(pr['number'])
|
|
323
|
+
return pr unless info
|
|
324
|
+
|
|
325
|
+
pr['state'] = info['state']
|
|
326
|
+
pr['title'] = info['title']
|
|
327
|
+
pr['checks'] = summarize_checks(info['statusCheckRollup'])
|
|
328
|
+
pr['last_checked'] = Time.now.iso8601
|
|
329
|
+
pr
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def summarize_checks(rollup)
|
|
333
|
+
return nil unless rollup
|
|
334
|
+
|
|
335
|
+
contexts = rollup.is_a?(Array) ? rollup : []
|
|
336
|
+
return nil if contexts.empty?
|
|
337
|
+
|
|
338
|
+
total = contexts.length
|
|
339
|
+
success = contexts.count { |c| c['conclusion'] == 'SUCCESS' }
|
|
340
|
+
pending = contexts.count { |c| c['status'] == 'PENDING' || c['status'] == 'IN_PROGRESS' }
|
|
341
|
+
failed = contexts.count { |c| c['conclusion'] == 'FAILURE' || c['conclusion'] == 'ERROR' }
|
|
342
|
+
|
|
343
|
+
{ 'total' => total, 'success' => success, 'pending' => pending, 'failed' => failed }
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def display_pinned(pr, idx = nil)
|
|
347
|
+
num = idx ? "#{(idx + 1).to_s.rjust(3)}." : ""
|
|
348
|
+
state_icon = case pr['state']
|
|
349
|
+
when 'MERGED' then '[M]'
|
|
350
|
+
when 'CLOSED' then '[X]'
|
|
351
|
+
else '[O]'
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
checks_str = if pr['checks']
|
|
355
|
+
c = pr['checks']
|
|
356
|
+
if c['failed'] > 0
|
|
357
|
+
" [#{c['success']}/#{c['total']} FAIL:#{c['failed']}]"
|
|
358
|
+
elsif c['pending'] > 0
|
|
359
|
+
" [#{c['success']}/#{c['total']} pending:#{c['pending']}]"
|
|
360
|
+
else
|
|
361
|
+
" [#{c['success']}/#{c['total']}]"
|
|
362
|
+
end
|
|
363
|
+
else
|
|
364
|
+
""
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
"#{num} #{state_icon} ##{pr['number']} #{pr['title']}#{checks_str}".strip
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
247
371
|
WATCH_BLOCK = lambda { |original_pr_number=nil, *args|
|
|
248
372
|
watch = hiiro.get_value(:watch)
|
|
249
373
|
fail_fast = hiiro.get_value(:fail_fast)
|
|
@@ -322,6 +446,221 @@ hiiro.add_subcmd(:current) { manager.current }
|
|
|
322
446
|
hiiro.add_subcmd(:open) { |pr_number=nil| manager.open(pr_number) }
|
|
323
447
|
hiiro.add_subcmd(:view) { |pr_number=nil| manager.view(pr_number) }
|
|
324
448
|
|
|
449
|
+
hiiro.add_subcmd(:select) do |*args|
|
|
450
|
+
# Get my open PRs
|
|
451
|
+
output = `gh pr list --author @me --state open --json number,title,headRefName 2>/dev/null`.strip
|
|
452
|
+
|
|
453
|
+
if output.empty?
|
|
454
|
+
STDERR.puts "No open PRs found"
|
|
455
|
+
next
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
prs = JSON.parse(output) rescue []
|
|
459
|
+
if prs.empty?
|
|
460
|
+
STDERR.puts "No open PRs found"
|
|
461
|
+
next
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
lines = prs.each_with_object({}) do |pr, h|
|
|
465
|
+
display = "##{pr['number']} [#{pr['headRefName']}] #{pr['title']}"
|
|
466
|
+
h[display] = pr['number'].to_s
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
selected = Hiiro::Sk.map_select(lines)
|
|
470
|
+
|
|
471
|
+
if selected
|
|
472
|
+
print selected
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# === PR Pinning ===
|
|
477
|
+
|
|
478
|
+
pinned_manager = PinnedPRManager.new
|
|
479
|
+
|
|
480
|
+
hiiro.add_subcmd(:pin) do |ref = nil, *args|
|
|
481
|
+
pr_info = nil
|
|
482
|
+
|
|
483
|
+
case ref
|
|
484
|
+
when '-', nil
|
|
485
|
+
if ref == '-'
|
|
486
|
+
# Pin current branch's PR
|
|
487
|
+
pr_info = pinned_manager.fetch_current_branch_pr
|
|
488
|
+
unless pr_info
|
|
489
|
+
puts "No PR found for current branch"
|
|
490
|
+
next
|
|
491
|
+
end
|
|
492
|
+
else
|
|
493
|
+
# Select from my PRs
|
|
494
|
+
my_prs = pinned_manager.fetch_my_prs
|
|
495
|
+
if my_prs.empty?
|
|
496
|
+
puts "No open PRs found"
|
|
497
|
+
next
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
lines = my_prs.each_with_object({}) do |pr, h|
|
|
501
|
+
display = "##{pr['number']} [#{pr['headRefName']}] #{pr['title']}"
|
|
502
|
+
h[display] = pr
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
pr_info = Hiiro::Sk.map_select(lines)
|
|
506
|
+
unless pr_info
|
|
507
|
+
puts "No PR selected"
|
|
508
|
+
next
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
when /^\d+$/
|
|
512
|
+
# Pin by PR number
|
|
513
|
+
pr_info = pinned_manager.fetch_pr_info(ref)
|
|
514
|
+
unless pr_info
|
|
515
|
+
puts "PR ##{ref} not found"
|
|
516
|
+
next
|
|
517
|
+
end
|
|
518
|
+
else
|
|
519
|
+
puts "Usage: h pr pin [-|PR_NUMBER]"
|
|
520
|
+
puts " - Pin current branch's PR"
|
|
521
|
+
puts " <num> Pin PR by number"
|
|
522
|
+
puts " (none) Select from your open PRs"
|
|
523
|
+
next
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
pinned_manager.pin(pr_info)
|
|
527
|
+
puts "Pinned PR ##{pr_info['number']}: #{pr_info['title']}"
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
hiiro.add_subcmd(:unpin) do |ref = nil, *args|
|
|
531
|
+
pinned = pinned_manager.load_pinned
|
|
532
|
+
|
|
533
|
+
if pinned.empty?
|
|
534
|
+
puts "No pinned PRs"
|
|
535
|
+
next
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
pr_number = nil
|
|
539
|
+
|
|
540
|
+
if ref.nil?
|
|
541
|
+
# Select from pinned PRs
|
|
542
|
+
lines = pinned.each_with_index.each_with_object({}) do |(pr, idx), h|
|
|
543
|
+
h[pinned_manager.display_pinned(pr, idx)] = pr['number'].to_s
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
pr_number = Hiiro::Sk.map_select(lines)
|
|
547
|
+
unless pr_number
|
|
548
|
+
puts "No PR selected"
|
|
549
|
+
next
|
|
550
|
+
end
|
|
551
|
+
elsif ref =~ /^\d+$/
|
|
552
|
+
pr_number = ref
|
|
553
|
+
else
|
|
554
|
+
puts "Usage: h pr unpin [PR_NUMBER]"
|
|
555
|
+
next
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
if pinned_manager.unpin(pr_number)
|
|
559
|
+
puts "Unpinned PR ##{pr_number}"
|
|
560
|
+
else
|
|
561
|
+
puts "PR ##{pr_number} was not pinned"
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
hiiro.add_subcmd(:pinned) do |*args|
|
|
566
|
+
pinned = pinned_manager.load_pinned
|
|
567
|
+
|
|
568
|
+
if pinned.empty?
|
|
569
|
+
puts "No pinned PRs"
|
|
570
|
+
next
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
puts "Pinned PRs:"
|
|
574
|
+
puts
|
|
575
|
+
|
|
576
|
+
pinned.each_with_index do |pr, idx|
|
|
577
|
+
puts pinned_manager.display_pinned(pr, idx)
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
hiiro.add_subcmd(:status) do |*args|
|
|
582
|
+
pinned = pinned_manager.load_pinned
|
|
583
|
+
|
|
584
|
+
if pinned.empty?
|
|
585
|
+
puts "No pinned PRs"
|
|
586
|
+
next
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
puts "Refreshing status for #{pinned.length} pinned PR(s)..."
|
|
590
|
+
puts
|
|
591
|
+
|
|
592
|
+
pinned.each_with_index do |pr, idx|
|
|
593
|
+
pinned_manager.refresh_status(pr)
|
|
594
|
+
puts pinned_manager.display_pinned(pr, idx)
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
pinned_manager.save_pinned(pinned)
|
|
598
|
+
puts
|
|
599
|
+
puts "Status updated at #{Time.now.strftime('%H:%M:%S')}"
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
hiiro.add_subcmd(:'suggest-unpin') do |*args|
|
|
603
|
+
pinned = pinned_manager.load_pinned
|
|
604
|
+
|
|
605
|
+
if pinned.empty?
|
|
606
|
+
puts "No pinned PRs"
|
|
607
|
+
next
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# Refresh all statuses first
|
|
611
|
+
pinned.each { |pr| pinned_manager.refresh_status(pr) }
|
|
612
|
+
pinned_manager.save_pinned(pinned)
|
|
613
|
+
|
|
614
|
+
merged = pinned.select { |pr| pr['state'] == 'MERGED' }
|
|
615
|
+
closed = pinned.select { |pr| pr['state'] == 'CLOSED' }
|
|
616
|
+
|
|
617
|
+
if merged.empty? && closed.empty?
|
|
618
|
+
puts "No merged or closed PRs to unpin"
|
|
619
|
+
next
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
puts "PRs that can be unpinned:"
|
|
623
|
+
puts
|
|
624
|
+
|
|
625
|
+
(merged + closed).each_with_index do |pr, idx|
|
|
626
|
+
puts pinned_manager.display_pinned(pr, idx)
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
puts
|
|
630
|
+
puts "Run 'h pr auto-unpin' to unpin all merged/closed PRs"
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
hiiro.add_subcmd(:'auto-unpin') do |*args|
|
|
634
|
+
pinned = pinned_manager.load_pinned
|
|
635
|
+
|
|
636
|
+
if pinned.empty?
|
|
637
|
+
puts "No pinned PRs"
|
|
638
|
+
next
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# Refresh all statuses first
|
|
642
|
+
pinned.each { |pr| pinned_manager.refresh_status(pr) }
|
|
643
|
+
|
|
644
|
+
merged_or_closed = pinned.select { |pr| pr['state'] == 'MERGED' || pr['state'] == 'CLOSED' }
|
|
645
|
+
|
|
646
|
+
if merged_or_closed.empty?
|
|
647
|
+
puts "No merged or closed PRs to unpin"
|
|
648
|
+
pinned_manager.save_pinned(pinned)
|
|
649
|
+
next
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
puts "Unpinning #{merged_or_closed.length} merged/closed PR(s):"
|
|
653
|
+
puts
|
|
654
|
+
|
|
655
|
+
merged_or_closed.each do |pr|
|
|
656
|
+
puts " ##{pr['number']}: #{pr['title']} [#{pr['state']}]"
|
|
657
|
+
pinned_manager.unpin(pr['number'])
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
puts
|
|
661
|
+
puts "Done. #{pinned_manager.load_pinned.length} PR(s) still pinned."
|
|
662
|
+
end
|
|
663
|
+
|
|
325
664
|
# hiiro.add_default { manager.help }
|
|
326
665
|
|
|
327
666
|
hiiro.run
|
data/bin/h-project
CHANGED
|
@@ -136,6 +136,32 @@ o.add_subcmd(:edit) { |*args|
|
|
|
136
136
|
exec(editor, projects_file)
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
# === SELECT PROJECT ===
|
|
140
|
+
|
|
141
|
+
o.add_subcmd(:select) { |*args|
|
|
142
|
+
dirs = project_dirs
|
|
143
|
+
conf = projects_from_config
|
|
144
|
+
|
|
145
|
+
all_projects = dirs.merge(conf)
|
|
146
|
+
|
|
147
|
+
if all_projects.empty?
|
|
148
|
+
STDERR.puts "No projects found"
|
|
149
|
+
next
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
lines = all_projects.each_with_object({}) do |(name, path), h|
|
|
153
|
+
source = conf.key?(name) ? '[config]' : '[dir]'
|
|
154
|
+
display = format("%-15s %s %s", name, source, path)
|
|
155
|
+
h[display] = path
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
selected = Hiiro::Sk.map_select(lines)
|
|
159
|
+
|
|
160
|
+
if selected
|
|
161
|
+
print selected
|
|
162
|
+
end
|
|
163
|
+
}
|
|
164
|
+
|
|
139
165
|
# === HELP ===
|
|
140
166
|
|
|
141
167
|
o.add_subcmd(:help) { |*args|
|
data/bin/h-remind
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'time'
|
|
6
|
+
require 'tempfile'
|
|
7
|
+
require 'hiiro'
|
|
8
|
+
|
|
9
|
+
class ReminderManager
|
|
10
|
+
REMINDERS_FILE = File.join(Dir.home, '.config/hiiro/reminders.yml')
|
|
11
|
+
REMINDER_TEMPLATE = {
|
|
12
|
+
'message' => '',
|
|
13
|
+
'interval' => '1h',
|
|
14
|
+
'sound' => nil,
|
|
15
|
+
'enabled' => true,
|
|
16
|
+
'once' => false,
|
|
17
|
+
'trigger_at' => nil,
|
|
18
|
+
'last_triggered' => nil,
|
|
19
|
+
'created_at' => Time.now.iso8601,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class Reminder
|
|
23
|
+
attr_accessor :message, :interval, :sound, :enabled, :once, :trigger_at, :last_triggered, :created_at
|
|
24
|
+
|
|
25
|
+
def initialize(message:, interval: '1h', sound: nil, enabled: true, once: false, trigger_at: nil, last_triggered: nil, created_at: Time.now.iso8601)
|
|
26
|
+
@message = message
|
|
27
|
+
@interval = interval
|
|
28
|
+
@sound = sound
|
|
29
|
+
@enabled = enabled
|
|
30
|
+
@once = once
|
|
31
|
+
@trigger_at = trigger_at
|
|
32
|
+
@last_triggered = last_triggered
|
|
33
|
+
@created_at = created_at
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.from_hash(hash)
|
|
37
|
+
new(
|
|
38
|
+
message: hash['message'],
|
|
39
|
+
interval: hash['interval'] || '1h',
|
|
40
|
+
sound: hash['sound'],
|
|
41
|
+
enabled: hash['enabled'] != false,
|
|
42
|
+
once: hash['once'] == true,
|
|
43
|
+
trigger_at: hash['trigger_at'],
|
|
44
|
+
last_triggered: hash['last_triggered'],
|
|
45
|
+
created_at: hash['created_at'] || Time.now.iso8601
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_h
|
|
50
|
+
{
|
|
51
|
+
'message' => @message,
|
|
52
|
+
'interval' => @interval,
|
|
53
|
+
'sound' => @sound,
|
|
54
|
+
'enabled' => @enabled,
|
|
55
|
+
'once' => @once,
|
|
56
|
+
'trigger_at' => @trigger_at,
|
|
57
|
+
'last_triggered' => @last_triggered,
|
|
58
|
+
'created_at' => @created_at
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_yaml
|
|
63
|
+
to_h.to_yaml
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def interval_seconds
|
|
67
|
+
parse_interval(@interval)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parse_interval(str)
|
|
71
|
+
return 3600 unless str # default 1 hour
|
|
72
|
+
|
|
73
|
+
match = str.to_s.match(/^(\d+)([smhdw])$/i)
|
|
74
|
+
return 3600 unless match
|
|
75
|
+
|
|
76
|
+
num = match[1].to_i
|
|
77
|
+
unit = match[2].downcase
|
|
78
|
+
|
|
79
|
+
case unit
|
|
80
|
+
when 's' then num
|
|
81
|
+
when 'm' then num * 60
|
|
82
|
+
when 'h' then num * 3600
|
|
83
|
+
when 'd' then num * 86400
|
|
84
|
+
when 'w' then num * 604800
|
|
85
|
+
else 3600
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def one_time?
|
|
90
|
+
@once == true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def should_trigger?
|
|
94
|
+
return false unless @enabled
|
|
95
|
+
|
|
96
|
+
if one_time?
|
|
97
|
+
# One-time reminder: check if trigger_at time has passed
|
|
98
|
+
return false if @last_triggered # already triggered
|
|
99
|
+
return false unless @trigger_at
|
|
100
|
+
|
|
101
|
+
trigger_time = Time.parse(@trigger_at) rescue nil
|
|
102
|
+
return false unless trigger_time
|
|
103
|
+
|
|
104
|
+
Time.now >= trigger_time
|
|
105
|
+
else
|
|
106
|
+
# Recurring reminder: check interval since last trigger
|
|
107
|
+
return true if @last_triggered.nil?
|
|
108
|
+
|
|
109
|
+
last_time = Time.parse(@last_triggered) rescue Time.at(0)
|
|
110
|
+
Time.now - last_time >= interval_seconds
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def trigger!
|
|
115
|
+
@last_triggered = Time.now.iso8601
|
|
116
|
+
# For one-time reminders, disable after triggering
|
|
117
|
+
@enabled = false if one_time?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def display_string(index = nil)
|
|
121
|
+
num = index ? "#{(index + 1).to_s.rjust(3)}." : ""
|
|
122
|
+
status = @enabled ? "[ON]" : "[OFF]"
|
|
123
|
+
sound_str = @sound ? " (#{@sound})" : ""
|
|
124
|
+
|
|
125
|
+
if one_time?
|
|
126
|
+
if @trigger_at
|
|
127
|
+
trigger_time = Time.parse(@trigger_at) rescue nil
|
|
128
|
+
time_str = trigger_time ? trigger_time.strftime('%Y-%m-%d %H:%M') : @trigger_at
|
|
129
|
+
"#{num} #{status} [ONCE @ #{time_str}] #{@message}#{sound_str}".strip
|
|
130
|
+
else
|
|
131
|
+
"#{num} #{status} [ONCE] #{@message}#{sound_str}".strip
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
"#{num} #{status} [#{@interval}] #{@message}#{sound_str}".strip
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Parse time argument - returns [is_once, trigger_at, interval]
|
|
140
|
+
def self.parse_time_arg(time_str)
|
|
141
|
+
return [false, nil, '1h'] if time_str.nil?
|
|
142
|
+
|
|
143
|
+
# Check for absolute time format: YYYYmmddHHMM
|
|
144
|
+
if time_str =~ /^(\d{12})$/
|
|
145
|
+
year = $1[0..3].to_i
|
|
146
|
+
month = $1[4..5].to_i
|
|
147
|
+
day = $1[6..7].to_i
|
|
148
|
+
hour = $1[8..9].to_i
|
|
149
|
+
min = $1[10..11].to_i
|
|
150
|
+
trigger_at = Time.new(year, month, day, hour, min).iso8601
|
|
151
|
+
return [true, trigger_at, nil]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Check for relative interval format: 15m, 2h, etc.
|
|
155
|
+
if time_str =~ /^(\d+)([smhdw])$/i
|
|
156
|
+
num = $1.to_i
|
|
157
|
+
unit = $2.downcase
|
|
158
|
+
seconds = case unit
|
|
159
|
+
when 's' then num
|
|
160
|
+
when 'm' then num * 60
|
|
161
|
+
when 'h' then num * 3600
|
|
162
|
+
when 'd' then num * 86400
|
|
163
|
+
when 'w' then num * 604800
|
|
164
|
+
else num * 60
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# This is a one-time reminder at a relative time
|
|
168
|
+
trigger_at = (Time.now + seconds).iso8601
|
|
169
|
+
return [true, trigger_at, time_str]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Default: treat as recurring interval
|
|
173
|
+
[false, nil, time_str]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
attr_reader :reminders_file
|
|
177
|
+
|
|
178
|
+
def initialize(reminders_file = REMINDERS_FILE)
|
|
179
|
+
@reminders_file = reminders_file
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def ensure_reminders_file
|
|
183
|
+
dir = File.dirname(@reminders_file)
|
|
184
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
185
|
+
File.write(@reminders_file, [].to_yaml) unless File.exist?(@reminders_file)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def load_reminders
|
|
189
|
+
ensure_reminders_file
|
|
190
|
+
data = YAML.load_file(@reminders_file) || []
|
|
191
|
+
data.map { |hash| Reminder.from_hash(hash) }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def save_reminders(reminders)
|
|
195
|
+
ensure_reminders_file
|
|
196
|
+
data = reminders.map(&:to_h)
|
|
197
|
+
File.write(@reminders_file, data.to_yaml)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def load_reminder_hash(reminders = nil)
|
|
201
|
+
reminders ||= load_reminders
|
|
202
|
+
reminders.each_with_index.each_with_object({}) do |(reminder, idx), h|
|
|
203
|
+
h[reminder.display_string(idx)] = reminder
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def edit_reminders(reminders = nil)
|
|
208
|
+
reminders_array = if reminders.nil?
|
|
209
|
+
[REMINDER_TEMPLATE]
|
|
210
|
+
elsif reminders.is_a?(Array)
|
|
211
|
+
reminders.map { |r| r.is_a?(Reminder) ? r.to_h : r }
|
|
212
|
+
else
|
|
213
|
+
[reminders.is_a?(Reminder) ? reminders.to_h : reminders]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
tmpfile = Tempfile.new(['reminder-edit-', '.yml'])
|
|
217
|
+
tmpfile.write(reminders_array.to_yaml)
|
|
218
|
+
tmpfile.close
|
|
219
|
+
|
|
220
|
+
system(ENV['EDITOR'] || 'vim', tmpfile.path)
|
|
221
|
+
|
|
222
|
+
updated_data = YAML.load_file(tmpfile.path)
|
|
223
|
+
tmpfile.unlink
|
|
224
|
+
|
|
225
|
+
updated_array = updated_data.is_a?(Array) ? updated_data : [updated_data]
|
|
226
|
+
updated_array.map { |hash| Reminder.from_hash(hash) }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def find_reminder_by_ref(ref, reminders)
|
|
230
|
+
if ref =~ /^\d+$/
|
|
231
|
+
idx = ref.to_i - 1
|
|
232
|
+
return [idx, reminders[idx]] if idx >= 0 && idx < reminders.length
|
|
233
|
+
end
|
|
234
|
+
[nil, nil]
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
rm = ReminderManager.new
|
|
239
|
+
o = Hiiro.init(*ARGV, plugins: [Pins], reminders_file: rm.reminders_file)
|
|
240
|
+
|
|
241
|
+
o.add_subcmd(:add) do |message = nil, time_arg = nil, sound = nil, *args|
|
|
242
|
+
reminders = rm.load_reminders
|
|
243
|
+
|
|
244
|
+
if message.nil?
|
|
245
|
+
new_reminders = rm.edit_reminders
|
|
246
|
+
|
|
247
|
+
reminders.concat(new_reminders)
|
|
248
|
+
rm.save_reminders(reminders)
|
|
249
|
+
|
|
250
|
+
if new_reminders.length == 1
|
|
251
|
+
puts "Saved reminder ##{reminders.length}: #{new_reminders.first.message}"
|
|
252
|
+
else
|
|
253
|
+
puts "Saved #{new_reminders.length} reminders (#{reminders.length - new_reminders.length + 1}-#{reminders.length})"
|
|
254
|
+
end
|
|
255
|
+
next
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
is_once, trigger_at, interval = ReminderManager.parse_time_arg(time_arg)
|
|
259
|
+
|
|
260
|
+
new_reminder = ReminderManager::Reminder.new(
|
|
261
|
+
message: message,
|
|
262
|
+
interval: interval,
|
|
263
|
+
sound: sound,
|
|
264
|
+
once: is_once,
|
|
265
|
+
trigger_at: trigger_at,
|
|
266
|
+
created_at: Time.now.iso8601
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
reminders << new_reminder
|
|
270
|
+
rm.save_reminders(reminders)
|
|
271
|
+
|
|
272
|
+
if is_once
|
|
273
|
+
trigger_time = Time.parse(trigger_at)
|
|
274
|
+
puts "Saved one-time reminder ##{reminders.length}: #{message} (at #{trigger_time.strftime('%Y-%m-%d %H:%M')})"
|
|
275
|
+
else
|
|
276
|
+
puts "Saved recurring reminder ##{reminders.length}: #{message} (every #{interval || '1h'})"
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
o.add_subcmd(:once) do |message = nil, time_arg = nil, sound = nil, *args|
|
|
281
|
+
reminders = rm.load_reminders
|
|
282
|
+
|
|
283
|
+
if message.nil?
|
|
284
|
+
puts "Usage: h remind once 'message' <time> [sound]"
|
|
285
|
+
puts " time: 15m, 2h, 1d OR YYYYmmddHHMM"
|
|
286
|
+
next
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
time_arg ||= '1h'
|
|
290
|
+
is_once, trigger_at, interval = ReminderManager.parse_time_arg(time_arg)
|
|
291
|
+
|
|
292
|
+
# Force one-time even if parse didn't detect it
|
|
293
|
+
unless trigger_at
|
|
294
|
+
seconds = ReminderManager::Reminder.new(message: '').parse_interval(time_arg)
|
|
295
|
+
trigger_at = (Time.now + seconds).iso8601
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
new_reminder = ReminderManager::Reminder.new(
|
|
299
|
+
message: message,
|
|
300
|
+
interval: interval,
|
|
301
|
+
sound: sound,
|
|
302
|
+
once: true,
|
|
303
|
+
trigger_at: trigger_at,
|
|
304
|
+
created_at: Time.now.iso8601
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
reminders << new_reminder
|
|
308
|
+
rm.save_reminders(reminders)
|
|
309
|
+
|
|
310
|
+
trigger_time = Time.parse(trigger_at)
|
|
311
|
+
puts "Saved one-time reminder ##{reminders.length}: #{message} (at #{trigger_time.strftime('%Y-%m-%d %H:%M')})"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
o.add_subcmd(:every) do |message = nil, interval = nil, sound = nil, *args|
|
|
315
|
+
reminders = rm.load_reminders
|
|
316
|
+
|
|
317
|
+
if message.nil?
|
|
318
|
+
puts "Usage: h remind every 'message' <interval> [sound]"
|
|
319
|
+
puts " interval: 15m, 2h, 1d, 1w"
|
|
320
|
+
next
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
new_reminder = ReminderManager::Reminder.new(
|
|
324
|
+
message: message,
|
|
325
|
+
interval: interval || '1h',
|
|
326
|
+
sound: sound,
|
|
327
|
+
once: false,
|
|
328
|
+
created_at: Time.now.iso8601
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
reminders << new_reminder
|
|
332
|
+
rm.save_reminders(reminders)
|
|
333
|
+
|
|
334
|
+
puts "Saved recurring reminder ##{reminders.length}: #{message} (every #{interval || '1h'})"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
o.add_subcmd(:ls) do |*args|
|
|
338
|
+
reminders = rm.load_reminders
|
|
339
|
+
if reminders.empty?
|
|
340
|
+
puts "No reminders saved."
|
|
341
|
+
else
|
|
342
|
+
reminders.each_with_index do |reminder, idx|
|
|
343
|
+
puts reminder.display_string(idx)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
o.add_subcmd(:list) do |*args|
|
|
349
|
+
o.run_subcmd(:ls, *args)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
o.add_subcmd(:remove) do |ref = nil, *args|
|
|
353
|
+
reminders = rm.load_reminders
|
|
354
|
+
|
|
355
|
+
if reminders.empty?
|
|
356
|
+
puts "No reminders to remove."
|
|
357
|
+
next
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
idx = nil
|
|
361
|
+
if ref.nil?
|
|
362
|
+
lines = rm.load_reminder_hash(reminders)
|
|
363
|
+
|
|
364
|
+
require 'open3'
|
|
365
|
+
selected, status = Open3.capture2('sk', stdin_data: lines.keys.join("\n"))
|
|
366
|
+
|
|
367
|
+
if status.success? && !selected.strip.empty?
|
|
368
|
+
reminder = lines[selected.strip]
|
|
369
|
+
idx = reminders.index(reminder)
|
|
370
|
+
end
|
|
371
|
+
else
|
|
372
|
+
idx, _ = rm.find_reminder_by_ref(ref, reminders)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
if idx.nil?
|
|
376
|
+
puts "Reminder not found"
|
|
377
|
+
next
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
removed = reminders.delete_at(idx)
|
|
381
|
+
rm.save_reminders(reminders)
|
|
382
|
+
puts "Removed reminder: #{removed.message}"
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
o.add_subcmd(:pause) do |ref = nil, *args|
|
|
386
|
+
reminders = rm.load_reminders
|
|
387
|
+
|
|
388
|
+
if reminders.empty?
|
|
389
|
+
puts "No reminders to pause."
|
|
390
|
+
next
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
idx = nil
|
|
394
|
+
if ref.nil?
|
|
395
|
+
enabled_reminders = reminders.select(&:enabled)
|
|
396
|
+
if enabled_reminders.empty?
|
|
397
|
+
puts "No enabled reminders to pause."
|
|
398
|
+
next
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
lines = enabled_reminders.each_with_index.each_with_object({}) do |(r, i), h|
|
|
402
|
+
actual_idx = reminders.index(r)
|
|
403
|
+
h[r.display_string(actual_idx)] = actual_idx
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
require 'open3'
|
|
407
|
+
selected, status = Open3.capture2('sk', stdin_data: lines.keys.join("\n"))
|
|
408
|
+
|
|
409
|
+
if status.success? && !selected.strip.empty?
|
|
410
|
+
idx = lines[selected.strip]
|
|
411
|
+
end
|
|
412
|
+
else
|
|
413
|
+
idx, _ = rm.find_reminder_by_ref(ref, reminders)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
if idx.nil?
|
|
417
|
+
puts "Reminder not found"
|
|
418
|
+
next
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
reminders[idx].enabled = false
|
|
422
|
+
rm.save_reminders(reminders)
|
|
423
|
+
puts "Paused reminder: #{reminders[idx].message}"
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
o.add_subcmd(:resume) do |ref = nil, *args|
|
|
427
|
+
reminders = rm.load_reminders
|
|
428
|
+
|
|
429
|
+
if reminders.empty?
|
|
430
|
+
puts "No reminders to resume."
|
|
431
|
+
next
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
idx = nil
|
|
435
|
+
if ref.nil?
|
|
436
|
+
disabled_reminders = reminders.reject(&:enabled)
|
|
437
|
+
if disabled_reminders.empty?
|
|
438
|
+
puts "No paused reminders to resume."
|
|
439
|
+
next
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
lines = disabled_reminders.each_with_index.each_with_object({}) do |(r, i), h|
|
|
443
|
+
actual_idx = reminders.index(r)
|
|
444
|
+
h[r.display_string(actual_idx)] = actual_idx
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
require 'open3'
|
|
448
|
+
selected, status = Open3.capture2('sk', stdin_data: lines.keys.join("\n"))
|
|
449
|
+
|
|
450
|
+
if status.success? && !selected.strip.empty?
|
|
451
|
+
idx = lines[selected.strip]
|
|
452
|
+
end
|
|
453
|
+
else
|
|
454
|
+
idx, _ = rm.find_reminder_by_ref(ref, reminders)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
if idx.nil?
|
|
458
|
+
puts "Reminder not found"
|
|
459
|
+
next
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
reminders[idx].enabled = true
|
|
463
|
+
rm.save_reminders(reminders)
|
|
464
|
+
puts "Resumed reminder: #{reminders[idx].message}"
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
o.add_subcmd(:check) do |*args|
|
|
468
|
+
reminders = rm.load_reminders
|
|
469
|
+
triggered_count = 0
|
|
470
|
+
verbose = args.include?('-v') || args.include?('--verbose')
|
|
471
|
+
|
|
472
|
+
reminders.each do |reminder|
|
|
473
|
+
next unless reminder.should_trigger?
|
|
474
|
+
|
|
475
|
+
# Build notification options
|
|
476
|
+
notification_args = ['-m', reminder.message, '-t', 'Reminder']
|
|
477
|
+
notification_args += ['-s', reminder.sound] if reminder.sound
|
|
478
|
+
|
|
479
|
+
# Use Hiiro::Notification to show the notification
|
|
480
|
+
notif_hiiro = Hiiro.init(*notification_args)
|
|
481
|
+
Hiiro::Notification.show(notif_hiiro)
|
|
482
|
+
|
|
483
|
+
reminder.trigger!
|
|
484
|
+
triggered_count += 1
|
|
485
|
+
|
|
486
|
+
if verbose
|
|
487
|
+
if reminder.one_time?
|
|
488
|
+
puts "Triggered (one-time): #{reminder.message}"
|
|
489
|
+
else
|
|
490
|
+
puts "Triggered: #{reminder.message}"
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
rm.save_reminders(reminders)
|
|
496
|
+
|
|
497
|
+
if verbose
|
|
498
|
+
enabled_count = reminders.count(&:enabled)
|
|
499
|
+
one_time_done = reminders.count { |r| r.one_time? && !r.enabled }
|
|
500
|
+
puts "Checked #{enabled_count} enabled reminders, triggered #{triggered_count}"
|
|
501
|
+
puts "#{one_time_done} completed one-time reminder(s) (use 'h remind clean' to remove)" if one_time_done > 0
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
o.add_subcmd(:clean) do |*args|
|
|
506
|
+
reminders = rm.load_reminders
|
|
507
|
+
before_count = reminders.length
|
|
508
|
+
|
|
509
|
+
# Remove disabled one-time reminders (already triggered)
|
|
510
|
+
reminders.reject! { |r| r.one_time? && !r.enabled }
|
|
511
|
+
|
|
512
|
+
after_count = reminders.length
|
|
513
|
+
removed = before_count - after_count
|
|
514
|
+
|
|
515
|
+
rm.save_reminders(reminders)
|
|
516
|
+
|
|
517
|
+
if removed > 0
|
|
518
|
+
puts "Removed #{removed} completed one-time reminder(s)"
|
|
519
|
+
else
|
|
520
|
+
puts "No completed one-time reminders to clean up"
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
o.add_subcmd(:howto) do |*args|
|
|
525
|
+
puts <<~HOWTO
|
|
526
|
+
h remind - Reminder notification system
|
|
527
|
+
|
|
528
|
+
SETUP FOR CRON:
|
|
529
|
+
===============
|
|
530
|
+
Add the following line to your crontab (run 'crontab -e'):
|
|
531
|
+
|
|
532
|
+
* * * * * #{File.expand_path('~/.rbenv/shims/h')} remind check
|
|
533
|
+
|
|
534
|
+
Or if you want verbose logging:
|
|
535
|
+
|
|
536
|
+
* * * * * #{File.expand_path('~/.rbenv/shims/h')} remind check -v >> ~/.config/hiiro/remind.log 2>&1
|
|
537
|
+
|
|
538
|
+
USAGE:
|
|
539
|
+
======
|
|
540
|
+
# One-time reminders (triggers once, then disables)
|
|
541
|
+
h remind add 'Read the news' 15m # Remind in 15 minutes
|
|
542
|
+
h remind add 'Meeting' 202602131430 # Remind at specific time (YYYYmmddHHMM)
|
|
543
|
+
h remind once 'Call mom' 2h # Explicit one-time in 2 hours
|
|
544
|
+
|
|
545
|
+
# Recurring reminders
|
|
546
|
+
h remind every 'Take a break' 30m # Remind every 30 minutes
|
|
547
|
+
h remind every 'Stand up' 1h basso # Remind hourly with 'basso' sound
|
|
548
|
+
|
|
549
|
+
# Editor mode
|
|
550
|
+
h remind add # Open editor to add reminder(s)
|
|
551
|
+
|
|
552
|
+
# Management
|
|
553
|
+
h remind ls # List all reminders
|
|
554
|
+
h remind remove # Select and remove a reminder
|
|
555
|
+
h remind remove 2 # Remove reminder #2
|
|
556
|
+
h remind clean # Remove completed one-time reminders
|
|
557
|
+
|
|
558
|
+
h remind pause # Select and pause a reminder
|
|
559
|
+
h remind pause 1 # Pause reminder #1
|
|
560
|
+
h remind resume # Select and resume a reminder
|
|
561
|
+
h remind resume 1 # Resume reminder #1
|
|
562
|
+
|
|
563
|
+
h remind check # Check and trigger due reminders
|
|
564
|
+
h remind check -v # Verbose mode
|
|
565
|
+
|
|
566
|
+
TIME FORMATS:
|
|
567
|
+
=============
|
|
568
|
+
Relative (one-time): 15m, 2h, 1d - triggers once after delay
|
|
569
|
+
Absolute (one-time): YYYYmmddHHMM - triggers at specific date/time
|
|
570
|
+
e.g., 202602131430 = Feb 13, 2026 at 14:30
|
|
571
|
+
|
|
572
|
+
Use 'h remind every' to create recurring reminders with intervals.
|
|
573
|
+
|
|
574
|
+
INTERVAL FORMAT (for recurring):
|
|
575
|
+
=================================
|
|
576
|
+
s = seconds (e.g., 30s)
|
|
577
|
+
m = minutes (e.g., 10m)
|
|
578
|
+
h = hours (e.g., 2h)
|
|
579
|
+
d = days (e.g., 1d)
|
|
580
|
+
w = weeks (e.g., 1w)
|
|
581
|
+
|
|
582
|
+
SOUNDS:
|
|
583
|
+
=======
|
|
584
|
+
Custom sounds can be placed in: ~/.config/hiiro/sounds/
|
|
585
|
+
Use the filename (without extension) as the sound name.
|
|
586
|
+
|
|
587
|
+
CONFIG FILE:
|
|
588
|
+
============
|
|
589
|
+
Reminders are stored in: #{ReminderManager::REMINDERS_FILE}
|
|
590
|
+
HOWTO
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
o.add_subcmd(:path) do |*args|
|
|
594
|
+
print rm.reminders_file
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
o.add_subcmd(:editall) do |*args|
|
|
598
|
+
reminders_before = rm.load_reminders
|
|
599
|
+
system(ENV['EDITOR'] || 'vim', rm.reminders_file)
|
|
600
|
+
|
|
601
|
+
begin
|
|
602
|
+
rm.load_reminders
|
|
603
|
+
rescue => e
|
|
604
|
+
puts "ERROR: Unable to read updated file...reverting."
|
|
605
|
+
rm.save_reminders(reminders_before)
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
begin
|
|
610
|
+
o.run
|
|
611
|
+
rescue => e
|
|
612
|
+
require 'pry'
|
|
613
|
+
binding.pry
|
|
614
|
+
end
|
data/bin/h-sha
CHANGED
|
@@ -2,9 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
require 'hiiro'
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Hiiro.init(*ARGV, plugins: [Pins]) { |hiiro|
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
hiiro.add_subcmd(:select) do |*args|
|
|
8
|
+
# Get git log with format: sha - subject
|
|
9
|
+
log_output = `git log --oneline -100 #{args.join(' ')}`.strip
|
|
10
|
+
|
|
11
|
+
if log_output.empty?
|
|
12
|
+
STDERR.puts "No commits found"
|
|
13
|
+
next
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
lines = log_output.split("\n").each_with_object({}) do |line, h|
|
|
17
|
+
sha = line.split.first
|
|
18
|
+
h[line] = sha
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
selected = Hiiro::Sk.map_select(lines)
|
|
22
|
+
|
|
23
|
+
if selected
|
|
24
|
+
print selected
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
hiiro.add_subcmd(:ls) do |*args|
|
|
29
|
+
system('git', 'log', '--oneline', *args)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
hiiro.add_subcmd(:show) do |sha = nil, *args|
|
|
33
|
+
if sha.nil?
|
|
34
|
+
sha = `h sha select`.strip
|
|
35
|
+
next if sha.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
system('git', 'show', sha, *args)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
hiiro.add_subcmd(:copy) do |sha = nil, *args|
|
|
42
|
+
if sha.nil?
|
|
43
|
+
sha = `h sha select`.strip
|
|
44
|
+
next if sha.empty?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
require 'open3'
|
|
48
|
+
Open3.popen3('pbcopy') do |stdin, stdout, stderr, wait_thr|
|
|
49
|
+
stdin.write(sha)
|
|
50
|
+
stdin.close
|
|
51
|
+
wait_thr.value
|
|
52
|
+
end
|
|
53
|
+
puts "Copied #{sha} to clipboard"
|
|
54
|
+
end
|
|
8
55
|
|
|
9
56
|
}.run
|
|
10
57
|
|
data/bin/h-window
CHANGED
|
@@ -68,4 +68,25 @@ o.add_subcmd(:history) { |window_id=nil|
|
|
|
68
68
|
end
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
o.add_subcmd(:select) do |*args|
|
|
72
|
+
# Get all windows with details
|
|
73
|
+
output = `tmux list-windows -a -F '\#{session_name}:\#{window_index} \#{window_name} [\#{window_panes} panes]'`.strip
|
|
74
|
+
|
|
75
|
+
if output.empty?
|
|
76
|
+
STDERR.puts "No windows found"
|
|
77
|
+
next
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
lines = output.split("\n").each_with_object({}) do |line, h|
|
|
81
|
+
window_id = line.split.first
|
|
82
|
+
h[line] = window_id
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
selected = Hiiro::Sk.map_select(lines)
|
|
86
|
+
|
|
87
|
+
if selected
|
|
88
|
+
print selected
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
71
92
|
o.run
|
data/bin/h-wtree
CHANGED
|
@@ -12,5 +12,27 @@ Hiiro.run(*ARGV) do |h|
|
|
|
12
12
|
h.add_subcmd(:remove) { |*args| system('git', 'worktree', 'remove', *args) }
|
|
13
13
|
h.add_subcmd(:repair) { |*args| system('git', 'worktree', 'repair', *args) }
|
|
14
14
|
h.add_subcmd(:unlock) { |*args| system('git', 'worktree', 'unlock', *args) }
|
|
15
|
+
|
|
16
|
+
h.add_subcmd(:select) do |*args|
|
|
17
|
+
# Get worktree list
|
|
18
|
+
output = `git worktree list`.strip
|
|
19
|
+
|
|
20
|
+
if output.empty?
|
|
21
|
+
STDERR.puts "No worktrees found"
|
|
22
|
+
next
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
lines = output.split("\n").each_with_object({}) do |line, hash|
|
|
26
|
+
# Format: /path/to/worktree sha [branch]
|
|
27
|
+
path = line.split.first
|
|
28
|
+
hash[line] = path
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
selected = Hiiro::Sk.map_select(lines)
|
|
32
|
+
|
|
33
|
+
if selected
|
|
34
|
+
print selected
|
|
35
|
+
end
|
|
36
|
+
end
|
|
15
37
|
end
|
|
16
38
|
|
data/lib/hiiro/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hiiro
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.81
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joshua Toyota
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: pry
|
|
@@ -82,6 +81,7 @@ files:
|
|
|
82
81
|
- bin/h-plugin
|
|
83
82
|
- bin/h-pr
|
|
84
83
|
- bin/h-project
|
|
84
|
+
- bin/h-remind
|
|
85
85
|
- bin/h-serve
|
|
86
86
|
- bin/h-session
|
|
87
87
|
- bin/h-sha
|
|
@@ -134,7 +134,6 @@ metadata:
|
|
|
134
134
|
homepage_uri: https://github.com/unixsuperhero/hiiro
|
|
135
135
|
source_code_uri: https://github.com/unixsuperhero/hiiro
|
|
136
136
|
changelog_uri: https://github.com/unixsuperhero/hiiro/blob/main/CHANGELOG.md
|
|
137
|
-
post_install_message:
|
|
138
137
|
rdoc_options: []
|
|
139
138
|
require_paths:
|
|
140
139
|
- lib
|
|
@@ -149,8 +148,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
149
148
|
- !ruby/object:Gem::Version
|
|
150
149
|
version: '0'
|
|
151
150
|
requirements: []
|
|
152
|
-
rubygems_version: 3.
|
|
153
|
-
signing_key:
|
|
151
|
+
rubygems_version: 3.6.9
|
|
154
152
|
specification_version: 4
|
|
155
153
|
summary: A lightweight CLI framework for Ruby
|
|
156
154
|
test_files: []
|