ruby_todo 1.0.3 → 1.0.7

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.
data/lib/ruby_todo/cli.rb CHANGED
@@ -13,148 +13,22 @@ require_relative "models/task"
13
13
  require_relative "models/template"
14
14
  require_relative "database"
15
15
  require_relative "commands/ai_assistant"
16
+ require_relative "formatters/display_formatter"
17
+ require_relative "concerns/statistics"
18
+ require_relative "concerns/task_filters"
19
+ require_relative "concerns/import_export"
20
+ require_relative "commands/notebook_commands"
21
+ require_relative "commands/template_commands"
16
22
 
17
23
  module RubyTodo
18
- module Export
19
- private
20
-
21
- def export_notebook(notebook)
22
- {
23
- "name" => notebook.name,
24
- "created_at" => notebook.created_at,
25
- "updated_at" => notebook.updated_at,
26
- "tasks" => notebook.tasks.map { |task| task_to_hash(task) }
27
- }
28
- end
29
-
30
- def export_all_notebooks(notebooks)
31
- {
32
- "notebooks" => notebooks.map { |notebook| export_notebook(notebook) }
33
- }
34
- end
35
-
36
- def task_to_hash(task)
37
- {
38
- "title" => task.title,
39
- "description" => task.description,
40
- "status" => task.status,
41
- "priority" => task.priority,
42
- "tags" => task.tags,
43
- "due_date" => task.due_date&.iso8601,
44
- "created_at" => task.created_at&.iso8601,
45
- "updated_at" => task.updated_at&.iso8601
46
- }
47
- end
48
-
49
- def export_to_json(data, filename)
50
- File.write(filename, JSON.pretty_generate(data))
51
- end
52
-
53
- def export_to_csv(data, filename)
54
- CSV.open(filename, "wb") do |csv|
55
- if data["notebooks"]
56
- export_multiple_notebooks_to_csv(data, csv)
57
- else
58
- export_single_notebook_to_csv(data, csv)
59
- end
60
- end
61
- end
62
-
63
- def export_multiple_notebooks_to_csv(data, csv)
64
- csv << ["Notebook", "Task ID", "Title", "Description", "Status", "Priority", "Tags", "Due Date",
65
- "Created At", "Updated At"]
66
-
67
- data["notebooks"].each do |notebook|
68
- notebook_name = notebook["name"]
69
- notebook["tasks"].each_with_index do |task, index|
70
- csv << [
71
- notebook_name,
72
- index + 1,
73
- task["title"],
74
- task["description"],
75
- task["status"],
76
- task["priority"],
77
- task["tags"],
78
- task["due_date"],
79
- task["created_at"],
80
- task["updated_at"]
81
- ]
82
- end
83
- end
84
- end
85
-
86
- def export_single_notebook_to_csv(data, csv)
87
- csv << ["Task ID", "Title", "Description", "Status", "Priority", "Tags", "Due Date", "Created At",
88
- "Updated At"]
89
-
90
- data["tasks"].each_with_index do |task, index|
91
- csv << [
92
- index + 1,
93
- task["title"],
94
- task["description"],
95
- task["status"],
96
- task["priority"],
97
- task["tags"],
98
- task["due_date"],
99
- task["created_at"],
100
- task["updated_at"]
101
- ]
102
- end
103
- end
104
- end
105
-
106
- module Import
107
- private
108
-
109
- def import_tasks(notebook, tasks_data)
110
- count = 0
111
-
112
- tasks_data.each do |task_data|
113
- # Convert ISO8601 string to Time object
114
- due_date = Time.parse(task_data["due_date"]) if task_data["due_date"]
115
-
116
- task = Task.create(
117
- notebook: notebook,
118
- title: task_data["title"],
119
- description: task_data["description"],
120
- status: task_data["status"] || "todo",
121
- priority: task_data["priority"],
122
- tags: task_data["tags"],
123
- due_date: due_date
124
- )
125
-
126
- count += 1 if task.persisted?
127
- end
128
-
129
- count
130
- end
131
-
132
- def import_all_notebooks(data)
133
- results = { notebooks: 0, tasks: 0 }
134
-
135
- data["notebooks"].each do |notebook_data|
136
- notebook_name = notebook_data["name"]
137
- notebook = Notebook.find_by(name: notebook_name)
138
-
139
- unless notebook
140
- notebook = Notebook.create(name: notebook_name)
141
- results[:notebooks] += 1 if notebook.persisted?
142
- end
143
-
144
- if notebook.persisted?
145
- tasks_count = import_tasks(notebook, notebook_data["tasks"])
146
- results[:tasks] += tasks_count
147
- end
148
- end
149
-
150
- results
151
- end
152
- end
153
-
154
24
  class CLI < Thor
155
25
  include Thor::Actions
156
- include Export
157
- include Import
26
+ include DisplayFormatter
27
+ include Statistics
28
+ include TaskFilters
29
+ include ImportExport
30
+ include NotebookCommands
31
+ include TemplateCommands
158
32
 
159
33
  map %w[--version -v] => :version
160
34
  desc "version", "Show the Ruby Todo version"
@@ -166,168 +40,6 @@ module RubyTodo
166
40
  true
167
41
  end
168
42
 
169
- # Notebook commands
170
- class NotebookCommand < Thor
171
- desc "create NAME", "Create a new notebook"
172
- def create(name)
173
- Notebook.create(name: name)
174
- puts "Created notebook: #{name}".green
175
- end
176
-
177
- desc "list", "List all notebooks"
178
- def list
179
- notebooks = Notebook.all
180
- if notebooks.empty?
181
- puts "No notebooks found. Create one with 'ruby_todo notebook create NAME'".yellow
182
- return
183
- end
184
-
185
- table = TTY::Table.new(
186
- header: ["ID", "Name", "Tasks", "Created At"],
187
- rows: notebooks.map { |n| [n.id, n.name, n.tasks.count, n.created_at] }
188
- )
189
- puts table.render(:ascii)
190
- end
191
- end
192
-
193
- # Template commands
194
- class TemplateCommand < Thor
195
- desc "create NAME", "Create a new task template"
196
- option :notebook, aliases: "-n", desc: "Notebook to associate this template with (optional)"
197
- option :title, aliases: "-t", desc: "Title pattern (required)", required: true
198
- option :description, aliases: "-d", desc: "Description pattern"
199
- option :priority, aliases: "-p", desc: "Priority (high, medium, low)"
200
- option :tags, aliases: "-g", desc: "Tags pattern"
201
- option :due, aliases: "-u", desc: "Due date offset (e.g., '2d', '1w', '3h')"
202
- def create(name)
203
- notebook = nil
204
- if options[:notebook]
205
- notebook = RubyTodo::Notebook.find_by(name: options[:notebook])
206
- unless notebook
207
- puts "Notebook '#{options[:notebook]}' not found."
208
- exit 1
209
- end
210
- end
211
-
212
- template = RubyTodo::Template.new(
213
- name: name,
214
- notebook: notebook,
215
- title_pattern: options[:title],
216
- description_pattern: options[:description],
217
- tags_pattern: options[:tags],
218
- priority: options[:priority],
219
- due_date_offset: options[:due]
220
- )
221
-
222
- if template.save
223
- puts "Template '#{name}' created successfully."
224
- else
225
- puts "Error creating template: #{template.errors.full_messages.join(", ")}"
226
- exit 1
227
- end
228
- end
229
-
230
- desc "list", "List all templates"
231
- def list
232
- templates = RubyTodo::Template.all
233
-
234
- if templates.empty?
235
- puts "No templates found. Create one with 'ruby_todo template create NAME'"
236
- return
237
- end
238
-
239
- table = TTY::Table.new(
240
- header: ["ID", "Name", "Title Pattern", "Notebook", "Priority", "Due Date Offset"],
241
- rows: templates.map do |template|
242
- [
243
- template.id,
244
- template.name,
245
- template.title_pattern,
246
- template.notebook&.name || "None",
247
- template.priority || "None",
248
- template.due_date_offset || "None"
249
- ]
250
- end
251
- )
252
-
253
- puts table.render(:unicode, padding: [0, 1])
254
- end
255
-
256
- desc "show NAME", "Show details of a specific template"
257
- def show(name)
258
- template = RubyTodo::Template.find_by(name: name)
259
-
260
- unless template
261
- puts "Template '#{name}' not found."
262
- exit 1
263
- end
264
-
265
- puts "Template Details:"
266
- puts "ID: #{template.id}"
267
- puts "Name: #{template.name}"
268
- puts "Notebook: #{template.notebook&.name || "None"}"
269
- puts "Title Pattern: #{template.title_pattern}"
270
- puts "Description Pattern: #{template.description_pattern || "None"}"
271
- puts "Tags Pattern: #{template.tags_pattern || "None"}"
272
- puts "Priority: #{template.priority || "None"}"
273
- puts "Due Date Offset: #{template.due_date_offset || "None"}"
274
- puts "Created At: #{template.created_at}"
275
- puts "Updated At: #{template.updated_at}"
276
- end
277
-
278
- desc "delete NAME", "Delete a template"
279
- def delete(name)
280
- template = RubyTodo::Template.find_by(name: name)
281
-
282
- unless template
283
- puts "Template '#{name}' not found."
284
- exit 1
285
- end
286
-
287
- if template.destroy
288
- puts "Template '#{name}' deleted successfully."
289
- else
290
- puts "Error deleting template: #{template.errors.full_messages.join(", ")}"
291
- exit 1
292
- end
293
- end
294
-
295
- desc "use NAME NOTEBOOK", "Create a task from a template in the specified notebook"
296
- option :replacements, aliases: "-r", desc: "Replacements for placeholders (e.g., 'item:Books,date:2023-12-31')"
297
- def use(name, notebook_name)
298
- template = RubyTodo::Template.find_by(name: name)
299
-
300
- unless template
301
- puts "Template '#{name}' not found."
302
- exit 1
303
- end
304
-
305
- notebook = RubyTodo::Notebook.find_by(name: notebook_name)
306
-
307
- unless notebook
308
- puts "Notebook '#{notebook_name}' not found."
309
- exit 1
310
- end
311
-
312
- replacements = {}
313
- if options[:replacements]
314
- options[:replacements].split(",").each do |r|
315
- key, value = r.split(":")
316
- replacements[key] = value if key && value
317
- end
318
- end
319
-
320
- task = template.create_task(notebook, replacements)
321
-
322
- if task.persisted?
323
- puts "Task created successfully with ID: #{task.id}"
324
- else
325
- puts "Error creating task: #{task.errors.full_messages.join(", ")}"
326
- exit 1
327
- end
328
- end
329
- end
330
-
331
43
  class_option :notebook, type: :string, desc: "Specify the notebook to use"
332
44
 
333
45
  def initialize(*args)
@@ -343,88 +55,6 @@ module RubyTodo
343
55
  say "Ruby Todo has been initialized successfully!".green
344
56
  end
345
57
 
346
- # Register subcommands with colon format
347
- desc "notebook:create NAME", "Create a new notebook"
348
- def notebook_create(name)
349
- Notebook.create(name: name)
350
- puts "Created notebook: #{name}".green
351
- end
352
-
353
- desc "notebook:list", "List all notebooks"
354
- def notebook_list
355
- notebooks = Notebook.all
356
- return say "No notebooks found".yellow if notebooks.empty?
357
-
358
- table = TTY::Table.new(
359
- header: ["ID", "Name", "Tasks", "Created At", "Default"],
360
- rows: notebooks.map do |notebook|
361
- [
362
- notebook.id,
363
- notebook.name,
364
- notebook.tasks.count,
365
- notebook.created_at,
366
- notebook.is_default? ? "✓" : ""
367
- ]
368
- end
369
- )
370
- puts table.render(:ascii)
371
- end
372
-
373
- desc "notebook:set_default NOTEBOOK", "Set a notebook as the default"
374
- def notebook_set_default(name)
375
- notebook = Notebook.find_by(name: name)
376
- if notebook
377
- notebook.make_default!
378
- say "Successfully set '#{name}' as the default notebook".green
379
- else
380
- say "Notebook '#{name}' not found".red
381
- end
382
- end
383
-
384
- desc "template:create NAME", "Create a new task template"
385
- def template_create(name)
386
- TemplateCommand.new.create(name)
387
- end
388
-
389
- desc "template:list", "List all templates"
390
- def template_list
391
- TemplateCommand.new.list
392
- end
393
-
394
- desc "template:show NAME", "Show details of a specific template"
395
- def template_show(name)
396
- TemplateCommand.new.show(name)
397
- end
398
-
399
- desc "template:delete NAME", "Delete a template"
400
- def template_delete(name)
401
- TemplateCommand.new.delete(name)
402
- end
403
-
404
- desc "template:use NAME NOTEBOOK", "Create a task from a template in the specified notebook"
405
- def template_use(name, notebook)
406
- TemplateCommand.new.use(name, notebook)
407
- end
408
-
409
- # Register AI Assistant subcommand with colon format
410
- desc "ai:ask PROMPT", "Ask the AI assistant to perform tasks using natural language"
411
- method_option :api_key, type: :string, desc: "OpenAI API key"
412
- method_option :verbose, type: :boolean, default: false, desc: "Show detailed response"
413
- def ai_ask(*prompt_args)
414
- prompt = prompt_args.join(" ")
415
- ai_command.ask(prompt)
416
- end
417
-
418
- desc "ai:configure", "Configure the AI assistant settings"
419
- def ai_configure
420
- ai_command.configure
421
- end
422
-
423
- # Map commands to use colon format
424
- map "notebook:list" => :notebook_list
425
- map "ai:ask" => :ai_ask
426
- map "ai:configure" => :ai_configure
427
-
428
58
  # Task commands
429
59
  desc "task:add [NOTEBOOK] [TITLE]", "Add a new task to a notebook"
430
60
  method_option :description, type: :string, desc: "Task description"
@@ -432,11 +62,8 @@ module RubyTodo
432
62
  method_option :priority, type: :string, desc: "Priority (high, medium, low)"
433
63
  method_option :tags, type: :string, desc: "Tags (comma-separated)"
434
64
  def task_add(notebook_name, title)
435
- notebook = Notebook.find_by(name: notebook_name)
436
- unless notebook
437
- say "Notebook '#{notebook_name}' not found".red
438
- return
439
- end
65
+ notebook = find_notebook(notebook_name)
66
+ return unless notebook
440
67
 
441
68
  description = options[:description]
442
69
  due_date = parse_due_date(options[:due_date]) if options[:due_date]
@@ -471,46 +98,17 @@ module RubyTodo
471
98
  method_option :overdue, type: :boolean, desc: "Show only overdue tasks"
472
99
  method_option :tags, type: :string, desc: "Filter by tags (comma-separated)"
473
100
  def task_list(notebook_name)
474
- notebook = Notebook.find_by(name: notebook_name)
475
- unless notebook
476
- say "Notebook '#{notebook_name}' not found".red
477
- return
478
- end
479
-
480
- tasks = notebook.tasks
101
+ notebook = find_notebook(notebook_name)
102
+ return unless notebook
481
103
 
482
- # Apply filters
483
- tasks = tasks.where(status: options[:status]) if options[:status]
484
- tasks = tasks.where(priority: options[:priority]) if options[:priority]
485
-
486
- if options[:tags]
487
- tag_filters = options[:tags].split(",").map(&:strip)
488
- tasks = tasks.select { |t| t.tags && tag_filters.any? { |tag| t.tags.include?(tag) } }
489
- end
490
-
491
- tasks = tasks.select(&:due_soon?) if options[:due_soon]
492
- tasks = tasks.select(&:overdue?) if options[:overdue]
104
+ tasks = apply_filters(notebook.tasks)
493
105
 
494
106
  if tasks.empty?
495
107
  say "No tasks found in notebook '#{notebook_name}'".yellow
496
108
  return
497
109
  end
498
110
 
499
- table = TTY::Table.new(
500
- header: ["ID", "Title", "Status", "Priority", "Due Date", "Tags", "Description"],
501
- rows: tasks.map do |t|
502
- [
503
- t.id,
504
- t.title,
505
- format_status(t.status),
506
- format_priority(t.priority),
507
- format_due_date(t.due_date),
508
- truncate_text(t.tags, 15),
509
- truncate_text(t.description, 30)
510
- ]
511
- end
512
- )
513
- puts table.render(:ascii)
111
+ display_tasks(tasks)
514
112
  end
515
113
 
516
114
  desc "task:show [NOTEBOOK] [TASK_ID]", "Show detailed information about a task"
@@ -795,7 +393,43 @@ module RubyTodo
795
393
  end
796
394
  end
797
395
 
798
- # Map all commands to use colon format
396
+ # AI Commands - explicitly defined
397
+ desc "ai:ask [PROMPT]", "Ask the AI assistant to perform tasks using natural language"
398
+ method_option :api_key, type: :string, desc: "OpenAI API key"
399
+ method_option :verbose, type: :boolean, default: false, desc: "Show detailed response"
400
+ def ai_ask(*prompt_args)
401
+ require_relative "commands/ai_assistant"
402
+ prompt = prompt_args.join(" ")
403
+ ai_command = AIAssistantCommand.new
404
+ ai_command.ask(prompt, verbose: options[:verbose], api_key: options[:api_key])
405
+ end
406
+
407
+ desc "ai:configure", "Configure the AI assistant settings"
408
+ def ai_configure
409
+ require_relative "commands/ai_assistant"
410
+ ai_command = AIAssistantCommand.new
411
+ ai_command.configure
412
+ end
413
+
414
+ # Define explicit methods for notebook commands to ensure Thor can see them
415
+ desc "notebook:create NAME", "Create a new notebook"
416
+
417
+ desc "notebook:list", "List all notebooks"
418
+
419
+ desc "notebook:set_default NOTEBOOK", "Set a notebook as the default"
420
+
421
+ # Register command descriptions for templates with explicit method definitions
422
+ desc "template:create NAME", "Create a new task template"
423
+
424
+ desc "template:list", "List all templates"
425
+
426
+ desc "template:show NAME", "Show details of a specific template"
427
+
428
+ desc "template:delete NAME", "Delete a template"
429
+
430
+ desc "template:use NAME NOTEBOOK", "Create a task from a template in the specified notebook"
431
+
432
+ # Map commands to use colon format
799
433
  map "task:add" => :task_add
800
434
  map "task:list" => :task_list
801
435
  map "task:show" => :task_show
@@ -811,52 +445,8 @@ module RubyTodo
811
445
  map "template:show" => :template_show
812
446
  map "template:delete" => :template_delete
813
447
  map "template:use" => :template_use
814
-
815
- # Remove old command mappings
816
- no_commands do
817
- def self.remove_old_commands
818
- remove_command :create
819
- remove_command :list
820
- remove_command :show
821
- remove_command :delete
822
- remove_command :use
823
- remove_command :ai
824
- end
825
- end
826
-
827
- remove_old_commands
828
-
829
- # Override the help command to show only colon-formatted commands
830
- def help(command = nil)
831
- if command.nil?
832
- puts "Commands:"
833
- puts " ruby_todo ai:ask PROMPT # Ask the AI assistant to perform tasks"
834
- puts " ruby_todo ai:configure # Configure the AI assistant settings"
835
- puts " ruby_todo notebook:create NAME # Create a new notebook"
836
- puts " ruby_todo notebook:list # List all notebooks"
837
- puts " ruby_todo notebook:set_default NAME # Set a notebook as the default"
838
- puts " ruby_todo task:add [NOTEBOOK] [TITLE] # Add a new task to a notebook"
839
- puts " ruby_todo task:list [NOTEBOOK] # List all tasks in a notebook"
840
- puts " ruby_todo task:show [NOTEBOOK] [TASK_ID] # Show task details"
841
- puts " ruby_todo task:edit [NOTEBOOK] [TASK_ID] # Edit a task"
842
- puts " ruby_todo task:move [NOTEBOOK] [TASK_ID] STATUS # Move a task to a different status"
843
- puts " ruby_todo task:delete [NOTEBOOK] [TASK_ID] # Delete a task"
844
- puts " ruby_todo task:search [QUERY] # Search for tasks"
845
- puts " ruby_todo template:create NAME # Create a task template"
846
- puts " ruby_todo template:list # List all templates"
847
- puts " ruby_todo template:show NAME # Show template details"
848
- puts " ruby_todo template:delete NAME # Delete a template"
849
- puts " ruby_todo template:use NAME NOTEBOOK # Use a template"
850
- puts " ruby_todo export [NOTEBOOK] [FILENAME] # Export tasks"
851
- puts " ruby_todo import [FILENAME] # Import tasks"
852
- puts " ruby_todo init # Initialize todo list"
853
- puts " ruby_todo version # Show version"
854
- puts "\nOptions:"
855
- puts " [--notebook=NOTEBOOK] # Specify the notebook to use"
856
- else
857
- super
858
- end
859
- end
448
+ map "ai:ask" => :ai_ask
449
+ map "ai:configure" => :ai_configure
860
450
 
861
451
  private
862
452
 
@@ -1003,8 +593,77 @@ module RubyTodo
1003
593
  text.length > length ? "#{text[0...length]}..." : text
1004
594
  end
1005
595
 
1006
- def ai_command
1007
- @ai_command ||= AIAssistantCommand.new
596
+ def find_notebook(notebook_name)
597
+ notebook = Notebook.find_by(name: notebook_name)
598
+ unless notebook
599
+ say "Notebook '#{notebook_name}' not found".red
600
+ return nil
601
+ end
602
+ notebook
603
+ end
604
+
605
+ def apply_filters(tasks)
606
+ tasks = apply_status_filter(tasks)
607
+ tasks = apply_priority_filter(tasks)
608
+ tasks = apply_tag_filter(tasks)
609
+ apply_due_date_filters(tasks)
610
+ end
611
+
612
+ def apply_status_filter(tasks)
613
+ return tasks unless options[:status]
614
+
615
+ tasks.where(status: options[:status])
616
+ end
617
+
618
+ def apply_priority_filter(tasks)
619
+ return tasks unless options[:priority]
620
+
621
+ tasks.where(priority: options[:priority])
622
+ end
623
+
624
+ def apply_tag_filter(tasks)
625
+ return tasks unless options[:tags]
626
+
627
+ tag_filters = options[:tags].split(",").map(&:strip)
628
+ tasks.select { |t| t.tags && tag_filters.any? { |tag| t.tags.include?(tag) } }
629
+ end
630
+
631
+ def apply_due_date_filters(tasks)
632
+ tasks = tasks.select(&:due_soon?) if options[:due_soon]
633
+ tasks = tasks.select(&:overdue?) if options[:overdue]
634
+ tasks
635
+ end
636
+
637
+ def display_tasks(tasks)
638
+ if ENV["RUBY_TODO_TEST"]
639
+ display_tasks_simple_format(tasks)
640
+ else
641
+ display_tasks_table_format(tasks)
642
+ end
643
+ end
644
+
645
+ def display_tasks_simple_format(tasks)
646
+ tasks.each do |t|
647
+ puts "#{t.id}: #{t.title} (#{t.status})"
648
+ end
649
+ end
650
+
651
+ def display_tasks_table_format(tasks)
652
+ table = TTY::Table.new(
653
+ header: ["ID", "Title", "Status", "Priority", "Due Date", "Tags", "Description"],
654
+ rows: tasks.map do |t|
655
+ [
656
+ t.id,
657
+ t.title,
658
+ format_status(t.status),
659
+ format_priority(t.priority),
660
+ format_due_date(t.due_date),
661
+ truncate_text(t.tags, 15),
662
+ truncate_text(t.description, 30)
663
+ ]
664
+ end
665
+ )
666
+ puts table.render(:ascii)
1008
667
  end
1009
668
  end
1010
669
  end