ruby_todo 0.3.0 → 0.3.2

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.
@@ -0,0 +1,853 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "colorize"
5
+ require "tty-prompt"
6
+ require "tty-table"
7
+ require "time"
8
+ require "json"
9
+ require "fileutils"
10
+ require_relative "models/notebook"
11
+ require_relative "models/task"
12
+ require_relative "models/template"
13
+ require_relative "database"
14
+
15
+ module RubyTodo
16
+ class CLI < Thor
17
+ include Thor::Actions
18
+
19
+ map %w[--version -v] => :version
20
+ desc "version", "Show the Ruby Todo version"
21
+ def version
22
+ puts "Ruby Todo version #{RubyTodo::VERSION}"
23
+ end
24
+
25
+ def self.exit_on_failure?
26
+ true
27
+ end
28
+
29
+ # Template commands
30
+ class TemplateCommand < Thor
31
+ desc "create NAME", "Create a new task template"
32
+ option :notebook, aliases: "-n", desc: "Notebook to associate this template with (optional)"
33
+ option :title, aliases: "-t", desc: "Title pattern (required)", required: true
34
+ option :description, aliases: "-d", desc: "Description pattern"
35
+ option :priority, aliases: "-p", desc: "Priority (high, medium, low)"
36
+ option :tags, aliases: "-g", desc: "Tags pattern"
37
+ option :due, aliases: "-u", desc: "Due date offset (e.g., '2d', '1w', '3h')"
38
+ def create(name)
39
+ notebook = nil
40
+ if options[:notebook]
41
+ notebook = RubyTodo::Notebook.find_by(name: options[:notebook])
42
+ unless notebook
43
+ puts "Notebook '#{options[:notebook]}' not found."
44
+ exit 1
45
+ end
46
+ end
47
+
48
+ template = RubyTodo::Template.new(
49
+ name: name,
50
+ notebook: notebook,
51
+ title_pattern: options[:title],
52
+ description_pattern: options[:description],
53
+ tags_pattern: options[:tags],
54
+ priority: options[:priority],
55
+ due_date_offset: options[:due]
56
+ )
57
+
58
+ if template.save
59
+ puts "Template '#{name}' created successfully."
60
+ else
61
+ puts "Error creating template: #{template.errors.full_messages.join(", ")}"
62
+ exit 1
63
+ end
64
+ end
65
+
66
+ desc "list", "List all templates"
67
+ def list
68
+ templates = RubyTodo::Template.all
69
+
70
+ if templates.empty?
71
+ puts "No templates found. Create one with 'ruby_todo template create NAME'"
72
+ return
73
+ end
74
+
75
+ table = TTY::Table.new(
76
+ header: ["ID", "Name", "Title Pattern", "Notebook", "Priority", "Due Date Offset"],
77
+ rows: templates.map do |template|
78
+ [
79
+ template.id,
80
+ template.name,
81
+ template.title_pattern,
82
+ template.notebook&.name || "None",
83
+ template.priority || "None",
84
+ template.due_date_offset || "None"
85
+ ]
86
+ end
87
+ )
88
+
89
+ puts table.render(:unicode, padding: [0, 1])
90
+ end
91
+
92
+ desc "show NAME", "Show details of a specific template"
93
+ def show(name)
94
+ template = RubyTodo::Template.find_by(name: name)
95
+
96
+ unless template
97
+ puts "Template '#{name}' not found."
98
+ exit 1
99
+ end
100
+
101
+ puts "Template Details:"
102
+ puts "ID: #{template.id}"
103
+ puts "Name: #{template.name}"
104
+ puts "Notebook: #{template.notebook&.name || "None"}"
105
+ puts "Title Pattern: #{template.title_pattern}"
106
+ puts "Description Pattern: #{template.description_pattern || "None"}"
107
+ puts "Tags Pattern: #{template.tags_pattern || "None"}"
108
+ puts "Priority: #{template.priority || "None"}"
109
+ puts "Due Date Offset: #{template.due_date_offset || "None"}"
110
+ puts "Created At: #{template.created_at}"
111
+ puts "Updated At: #{template.updated_at}"
112
+ end
113
+
114
+ desc "delete NAME", "Delete a template"
115
+ def delete(name)
116
+ template = RubyTodo::Template.find_by(name: name)
117
+
118
+ unless template
119
+ puts "Template '#{name}' not found."
120
+ exit 1
121
+ end
122
+
123
+ if template.destroy
124
+ puts "Template '#{name}' deleted successfully."
125
+ else
126
+ puts "Error deleting template: #{template.errors.full_messages.join(", ")}"
127
+ exit 1
128
+ end
129
+ end
130
+
131
+ desc "use NAME NOTEBOOK", "Create a task from a template in the specified notebook"
132
+ option :replacements, aliases: "-r", desc: "Replacements for placeholders (e.g., 'item:Books,date:2023-12-31')"
133
+ def use(name, notebook_name)
134
+ template = RubyTodo::Template.find_by(name: name)
135
+
136
+ unless template
137
+ puts "Template '#{name}' not found."
138
+ exit 1
139
+ end
140
+
141
+ notebook = RubyTodo::Notebook.find_by(name: notebook_name)
142
+
143
+ unless notebook
144
+ puts "Notebook '#{notebook_name}' not found."
145
+ exit 1
146
+ end
147
+
148
+ replacements = {}
149
+ if options[:replacements]
150
+ options[:replacements].split(",").each do |r|
151
+ key, value = r.split(":")
152
+ replacements[key] = value if key && value
153
+ end
154
+ end
155
+
156
+ task = template.create_task(notebook, replacements)
157
+
158
+ if task.persisted?
159
+ puts "Task created successfully with ID: #{task.id}"
160
+ else
161
+ puts "Error creating task: #{task.errors.full_messages.join(", ")}"
162
+ exit 1
163
+ end
164
+ end
165
+ end
166
+
167
+ class_option :notebook, type: :string, desc: "Specify the notebook to use"
168
+
169
+ def initialize(*args)
170
+ super
171
+ @prompt = TTY::Prompt.new
172
+ Database.setup
173
+ end
174
+
175
+ desc "init", "Initialize a new todo list"
176
+ def init
177
+ say "Initializing Ruby Todo...".green
178
+ Database.setup
179
+ say "Ruby Todo has been initialized successfully!".green
180
+ end
181
+
182
+ desc "notebook create [NAME]", "Create a new notebook"
183
+ def create(name)
184
+ Notebook.create(name: name)
185
+ say "Created notebook: #{name}".green
186
+ end
187
+
188
+ desc "notebook list", "List all notebooks"
189
+ def list
190
+ notebooks = Notebook.all
191
+ if notebooks.empty?
192
+ say "No notebooks found. Create one with 'ruby_todo notebook create [NAME]'".yellow
193
+ return
194
+ end
195
+
196
+ table = TTY::Table.new(
197
+ header: ["ID", "Name", "Tasks", "Created At"],
198
+ rows: notebooks.map { |n| [n.id, n.name, n.tasks.count, n.created_at] }
199
+ )
200
+ puts table.render(:ascii)
201
+ end
202
+
203
+ desc "task add [NOTEBOOK] [TITLE]", "Add a new task to a notebook"
204
+ method_option :description, type: :string, desc: "Task description"
205
+ method_option :due_date, type: :string, desc: "Due date (YYYY-MM-DD HH:MM)"
206
+ method_option :priority, type: :string, desc: "Priority (high, medium, low)"
207
+ method_option :tags, type: :string, desc: "Tags (comma-separated)"
208
+ def add(notebook_name, title)
209
+ notebook = Notebook.find_by(name: notebook_name)
210
+ unless notebook
211
+ say "Notebook '#{notebook_name}' not found".red
212
+ return
213
+ end
214
+
215
+ description = options[:description]
216
+ due_date = parse_due_date(options[:due_date]) if options[:due_date]
217
+ priority = options[:priority]
218
+ tags = options[:tags]&.split(",")&.map(&:strip)&.join(",")
219
+
220
+ task = Task.create(
221
+ notebook: notebook,
222
+ title: title,
223
+ description: description,
224
+ due_date: due_date,
225
+ priority: priority,
226
+ tags: tags,
227
+ status: "todo"
228
+ )
229
+
230
+ if task.valid?
231
+ say "Added task: #{title}".green
232
+ say "Description: #{description}" if description
233
+ say "Due date: #{format_due_date(due_date)}" if due_date
234
+ say "Priority: #{priority}" if priority
235
+ say "Tags: #{tags}" if tags
236
+ else
237
+ say "Error creating task: #{task.errors.full_messages.join(", ")}".red
238
+ end
239
+ end
240
+
241
+ desc "task list [NOTEBOOK]", "List all tasks in a notebook"
242
+ method_option :status, type: :string, desc: "Filter by status (todo, in_progress, done, archived)"
243
+ method_option :priority, type: :string, desc: "Filter by priority (high, medium, low)"
244
+ method_option :due_soon, type: :boolean, desc: "Show only tasks due soon (within 24 hours)"
245
+ method_option :overdue, type: :boolean, desc: "Show only overdue tasks"
246
+ method_option :tags, type: :string, desc: "Filter by tags (comma-separated)"
247
+ def task_list(notebook_name)
248
+ notebook = Notebook.find_by(name: notebook_name)
249
+ unless notebook
250
+ say "Notebook '#{notebook_name}' not found".red
251
+ return
252
+ end
253
+
254
+ tasks = notebook.tasks
255
+
256
+ # Apply filters
257
+ tasks = tasks.where(status: options[:status]) if options[:status]
258
+ tasks = tasks.where(priority: options[:priority]) if options[:priority]
259
+
260
+ if options[:tags]
261
+ tag_filters = options[:tags].split(",").map(&:strip)
262
+ tasks = tasks.select { |t| t.tags && tag_filters.any? { |tag| t.tags.include?(tag) } }
263
+ end
264
+
265
+ tasks = tasks.select(&:due_soon?) if options[:due_soon]
266
+ tasks = tasks.select(&:overdue?) if options[:overdue]
267
+
268
+ if tasks.empty?
269
+ say "No tasks found in notebook '#{notebook_name}'".yellow
270
+ return
271
+ end
272
+
273
+ table = TTY::Table.new(
274
+ header: ["ID", "Title", "Status", "Priority", "Due Date", "Tags", "Description"],
275
+ rows: tasks.map do |t|
276
+ [
277
+ t.id,
278
+ t.title,
279
+ format_status(t.status),
280
+ format_priority(t.priority),
281
+ format_due_date(t.due_date),
282
+ truncate_text(t.tags, 15),
283
+ truncate_text(t.description, 30)
284
+ ]
285
+ end
286
+ )
287
+ puts table.render(:ascii)
288
+ end
289
+
290
+ desc "task show [NOTEBOOK] [TASK_ID]", "Show detailed information about a task"
291
+ def task_show(notebook_name, task_id)
292
+ notebook = Notebook.find_by(name: notebook_name)
293
+ unless notebook
294
+ say "Notebook '#{notebook_name}' not found".red
295
+ return
296
+ end
297
+
298
+ task = notebook.tasks.find_by(id: task_id)
299
+ unless task
300
+ say "Task with ID #{task_id} not found".red
301
+ return
302
+ end
303
+
304
+ say "\nTask Details:".green
305
+ say "ID: #{task.id}"
306
+ say "Title: #{task.title}"
307
+ say "Status: #{format_status(task.status)}"
308
+ say "Priority: #{format_priority(task.priority) || "None"}"
309
+ say "Due Date: #{format_due_date(task.due_date)}"
310
+ say "Tags: #{task.tags || "None"}"
311
+ say "Description: #{task.description || "No description"}"
312
+ say "Created: #{task.created_at}"
313
+ say "Updated: #{task.updated_at}"
314
+ end
315
+
316
+ desc "task edit [NOTEBOOK] [TASK_ID]", "Edit an existing task"
317
+ method_option :title, type: :string, desc: "New title"
318
+ method_option :description, type: :string, desc: "New description"
319
+ method_option :due_date, type: :string, desc: "New due date (YYYY-MM-DD HH:MM)"
320
+ method_option :priority, type: :string, desc: "New priority (high, medium, low)"
321
+ method_option :tags, type: :string, desc: "New tags (comma-separated)"
322
+ method_option :status, type: :string, desc: "New status (todo, in_progress, done, archived)"
323
+ def edit(notebook_name, task_id)
324
+ notebook = Notebook.find_by(name: notebook_name)
325
+ unless notebook
326
+ say "Notebook '#{notebook_name}' not found".red
327
+ return
328
+ end
329
+
330
+ task = notebook.tasks.find_by(id: task_id)
331
+ unless task
332
+ say "Task with ID #{task_id} not found".red
333
+ return
334
+ end
335
+
336
+ updates = {}
337
+ updates[:title] = options[:title] if options[:title]
338
+ updates[:description] = options[:description] if options[:description]
339
+ updates[:priority] = options[:priority] if options[:priority]
340
+ updates[:status] = options[:status] if options[:status]
341
+ updates[:tags] = options[:tags] if options[:tags]
342
+
343
+ if options[:due_date]
344
+ updates[:due_date] = parse_due_date(options[:due_date])
345
+ end
346
+
347
+ if updates.empty?
348
+ say "No updates specified. Use --title, --description, etc. to specify updates.".yellow
349
+ return
350
+ end
351
+
352
+ if task.update(updates)
353
+ say "Updated task #{task_id}".green
354
+ else
355
+ say "Error updating task: #{task.errors.full_messages.join(", ")}".red
356
+ end
357
+ end
358
+
359
+ desc "task move [NOTEBOOK] [TASK_ID] [STATUS]", "Move a task to a different status"
360
+ def move(notebook_name, task_id, status)
361
+ notebook = Notebook.find_by(name: notebook_name)
362
+ unless notebook
363
+ say "Notebook '#{notebook_name}' not found".red
364
+ return
365
+ end
366
+
367
+ task = notebook.tasks.find_by(id: task_id)
368
+ unless task
369
+ say "Task with ID #{task_id} not found".red
370
+ return
371
+ end
372
+
373
+ if task.update(status: status)
374
+ say "Moved task #{task_id} to #{status}".green
375
+ else
376
+ say "Error moving task: #{task.errors.full_messages.join(", ")}".red
377
+ end
378
+ end
379
+
380
+ desc "task delete [NOTEBOOK] [TASK_ID]", "Delete a task"
381
+ def delete(notebook_name, task_id)
382
+ notebook = Notebook.find_by(name: notebook_name)
383
+ unless notebook
384
+ say "Notebook '#{notebook_name}' not found".red
385
+ return
386
+ end
387
+
388
+ task = notebook.tasks.find_by(id: task_id)
389
+ unless task
390
+ say "Task with ID #{task_id} not found".red
391
+ return
392
+ end
393
+
394
+ task.destroy
395
+ say "Deleted task #{task_id}".green
396
+ end
397
+
398
+ desc "task search [QUERY]", "Search for tasks across all notebooks"
399
+ method_option :notebook, type: :string, desc: "Limit search to a specific notebook"
400
+ def search(query)
401
+ notebooks = if options[:notebook]
402
+ [Notebook.find_by(name: options[:notebook])].compact
403
+ else
404
+ Notebook.all
405
+ end
406
+
407
+ if notebooks.empty?
408
+ say "No notebooks found".yellow
409
+ return
410
+ end
411
+
412
+ results = []
413
+ notebooks.each do |notebook|
414
+ notebook.tasks.each do |task|
415
+ next unless task.title.downcase.include?(query.downcase) ||
416
+ (task.description && task.description.downcase.include?(query.downcase)) ||
417
+ (task.tags && task.tags.downcase.include?(query.downcase))
418
+
419
+ results << [notebook.name, task.id, task.title, format_status(task.status)]
420
+ end
421
+ end
422
+
423
+ if results.empty?
424
+ say "No tasks matching '#{query}' found".yellow
425
+ return
426
+ end
427
+
428
+ table = TTY::Table.new(
429
+ header: %w[Notebook ID Title Status],
430
+ rows: results
431
+ )
432
+ puts table.render(:ascii)
433
+ end
434
+
435
+ desc "stats [NOTEBOOK]", "Show statistics for a notebook or all notebooks"
436
+ def stats(notebook_name = nil)
437
+ if notebook_name
438
+ notebook = Notebook.find_by(name: notebook_name)
439
+ unless notebook
440
+ say "Notebook '#{notebook_name}' not found".red
441
+ return
442
+ end
443
+ display_notebook_stats(notebook)
444
+ else
445
+ display_global_stats
446
+ end
447
+ end
448
+
449
+ desc "export [NOTEBOOK] [FILENAME]", "Export tasks from a notebook to a JSON file"
450
+ method_option :format, type: :string, default: "json", desc: "Export format (json or csv)"
451
+ method_option :all, type: :boolean, desc: "Export all notebooks"
452
+ def export(notebook_name = nil, filename = nil)
453
+ # Determine what to export
454
+ if options[:all]
455
+ notebooks = Notebook.all
456
+ if notebooks.empty?
457
+ say "No notebooks found".yellow
458
+ return
459
+ end
460
+
461
+ data = export_all_notebooks(notebooks)
462
+ filename ||= "ruby_todo_export_all_#{Time.now.strftime("%Y%m%d_%H%M%S")}"
463
+ else
464
+ unless notebook_name
465
+ say "Please specify a notebook name or use --all to export all notebooks".red
466
+ return
467
+ end
468
+
469
+ notebook = Notebook.find_by(name: notebook_name)
470
+ unless notebook
471
+ say "Notebook '#{notebook_name}' not found".red
472
+ return
473
+ end
474
+
475
+ data = export_notebook(notebook)
476
+ filename ||= "ruby_todo_export_#{notebook_name}_#{Time.now.strftime("%Y%m%d_%H%M%S")}"
477
+ end
478
+
479
+ # Ensure export directory exists
480
+ export_dir = File.expand_path("~/.ruby_todo/exports")
481
+ FileUtils.mkdir_p(export_dir)
482
+
483
+ # Determine export format and save
484
+ if options[:format].downcase == "csv"
485
+ filename = "#{filename}.csv" unless filename.end_with?(".csv")
486
+ export_path = File.join(export_dir, filename)
487
+ export_to_csv(data, export_path)
488
+ else
489
+ filename = "#{filename}.json" unless filename.end_with?(".json")
490
+ export_path = File.join(export_dir, filename)
491
+ export_to_json(data, export_path)
492
+ end
493
+
494
+ say "Tasks exported to #{export_path}".green
495
+ end
496
+
497
+ desc "import [FILENAME]", "Import tasks from a JSON or CSV file"
498
+ method_option :format, type: :string, default: "json", desc: "Import format (json or csv)"
499
+ method_option :notebook, type: :string, desc: "Target notebook for imported tasks"
500
+ def import(filename)
501
+ # Validate file exists
502
+ unless File.exist?(filename)
503
+ expanded_path = File.expand_path(filename)
504
+ if File.exist?(expanded_path)
505
+ filename = expanded_path
506
+ else
507
+ export_dir = File.expand_path("~/.ruby_todo/exports")
508
+ full_path = File.join(export_dir, filename)
509
+
510
+ unless File.exist?(full_path)
511
+ say "File '#{filename}' not found".red
512
+ return
513
+ end
514
+
515
+ filename = full_path
516
+ end
517
+ end
518
+
519
+ # Determine import format from file extension if not specified
520
+ format = options[:format].downcase
521
+ if format != "json" && format != "csv"
522
+ if filename.end_with?(".json")
523
+ format = "json"
524
+ elsif filename.end_with?(".csv")
525
+ format = "csv"
526
+ else
527
+ say "Unsupported file format. Please use .json or .csv files".red
528
+ return
529
+ end
530
+ end
531
+
532
+ # Parse the file
533
+ begin
534
+ if format == "json"
535
+ data = JSON.parse(File.read(filename))
536
+ else
537
+ say "CSV import is not yet implemented".red
538
+ return
539
+ end
540
+ rescue JSON::ParserError => e
541
+ say "Error parsing JSON file: #{e.message}".red
542
+ return
543
+ rescue StandardError => e
544
+ say "Error reading file: #{e.message}".red
545
+ return
546
+ end
547
+
548
+ # Import the data
549
+ if data.key?("notebooks")
550
+ # This is a full export with multiple notebooks
551
+ imported = import_all_notebooks(data)
552
+ say "Imported #{imported[:notebooks]} notebooks with #{imported[:tasks]} tasks".green
553
+ else
554
+ # This is a single notebook export
555
+ notebook_name = options[:notebook] || data["name"]
556
+ notebook = Notebook.find_by(name: notebook_name)
557
+
558
+ unless notebook
559
+ if @prompt.yes?("Notebook '#{notebook_name}' does not exist. Create it?")
560
+ notebook = Notebook.create(name: notebook_name)
561
+ else
562
+ say "Import cancelled".yellow
563
+ return
564
+ end
565
+ end
566
+
567
+ count = import_tasks(notebook, data["tasks"])
568
+ say "Imported #{count} tasks into notebook '#{notebook.name}'".green
569
+ end
570
+ end
571
+
572
+ desc "template SUBCOMMAND", "Manage task templates"
573
+ subcommand "template", TemplateCommand
574
+
575
+ # Task-related command aliases
576
+ map "task:list" => "task_list"
577
+ map "task:show" => "task_show"
578
+ map "task:add" => "add"
579
+ map "task:edit" => "edit"
580
+ map "task:delete" => "delete"
581
+ map "task:move" => "move"
582
+ map "task:search" => "search"
583
+
584
+ private
585
+
586
+ def export_notebook(notebook)
587
+ {
588
+ "name" => notebook.name,
589
+ "created_at" => notebook.created_at,
590
+ "updated_at" => notebook.updated_at,
591
+ "tasks" => notebook.tasks.map { |task| task_to_hash(task) }
592
+ }
593
+ end
594
+
595
+ def export_all_notebooks(notebooks)
596
+ {
597
+ "notebooks" => notebooks.map { |notebook| export_notebook(notebook) }
598
+ }
599
+ end
600
+
601
+ def task_to_hash(task)
602
+ {
603
+ "title" => task.title,
604
+ "description" => task.description,
605
+ "status" => task.status,
606
+ "priority" => task.priority,
607
+ "tags" => task.tags,
608
+ "due_date" => task.due_date&.iso8601,
609
+ "created_at" => task.created_at&.iso8601,
610
+ "updated_at" => task.updated_at&.iso8601
611
+ }
612
+ end
613
+
614
+ def export_to_json(data, filename)
615
+ File.write(filename, JSON.pretty_generate(data))
616
+ end
617
+
618
+ def export_to_csv(data, filename)
619
+ require "csv"
620
+
621
+ CSV.open(filename, "wb") do |csv|
622
+ if data["notebooks"]
623
+ # Multiple notebooks export
624
+ csv << ["Notebook", "Task ID", "Title", "Description", "Status", "Priority", "Tags", "Due Date",
625
+ "Created At", "Updated At"]
626
+
627
+ data["notebooks"].each do |notebook|
628
+ notebook_name = notebook["name"]
629
+ notebook["tasks"].each_with_index do |task, index|
630
+ csv << [
631
+ notebook_name,
632
+ index + 1,
633
+ task["title"],
634
+ task["description"],
635
+ task["status"],
636
+ task["priority"],
637
+ task["tags"],
638
+ task["due_date"],
639
+ task["created_at"],
640
+ task["updated_at"]
641
+ ]
642
+ end
643
+ end
644
+ else
645
+ # Single notebook export
646
+ csv << ["Task ID", "Title", "Description", "Status", "Priority", "Tags", "Due Date", "Created At",
647
+ "Updated At"]
648
+
649
+ data["tasks"].each_with_index do |task, index|
650
+ csv << [
651
+ index + 1,
652
+ task["title"],
653
+ task["description"],
654
+ task["status"],
655
+ task["priority"],
656
+ task["tags"],
657
+ task["due_date"],
658
+ task["created_at"],
659
+ task["updated_at"]
660
+ ]
661
+ end
662
+ end
663
+ end
664
+ end
665
+
666
+ def import_tasks(notebook, tasks_data)
667
+ count = 0
668
+
669
+ tasks_data.each do |task_data|
670
+ # Convert ISO8601 string to Time object
671
+ due_date = Time.parse(task_data["due_date"]) if task_data["due_date"]
672
+
673
+ task = Task.create(
674
+ notebook: notebook,
675
+ title: task_data["title"],
676
+ description: task_data["description"],
677
+ status: task_data["status"] || "todo",
678
+ priority: task_data["priority"],
679
+ tags: task_data["tags"],
680
+ due_date: due_date
681
+ )
682
+
683
+ count += 1 if task.persisted?
684
+ end
685
+
686
+ count
687
+ end
688
+
689
+ def import_all_notebooks(data)
690
+ results = { notebooks: 0, tasks: 0 }
691
+
692
+ data["notebooks"].each do |notebook_data|
693
+ notebook_name = notebook_data["name"]
694
+ notebook = Notebook.find_by(name: notebook_name)
695
+
696
+ unless notebook
697
+ notebook = Notebook.create(name: notebook_name)
698
+ results[:notebooks] += 1 if notebook.persisted?
699
+ end
700
+
701
+ if notebook.persisted?
702
+ tasks_count = import_tasks(notebook, notebook_data["tasks"])
703
+ results[:tasks] += tasks_count
704
+ end
705
+ end
706
+
707
+ results
708
+ end
709
+
710
+ def display_notebook_stats(notebook)
711
+ stats = notebook.statistics
712
+
713
+ say "\nStatistics for notebook: #{notebook.name}".green
714
+ say "\nTask Counts:".blue
715
+ say "Total: #{stats[:total]}"
716
+ say "Todo: #{stats[:todo]}"
717
+ say "In Progress: #{stats[:in_progress]}"
718
+ say "Done: #{stats[:done]}"
719
+ say "Archived: #{stats[:archived]}"
720
+
721
+ say "\nDue Dates:".blue
722
+ say "Overdue: #{stats[:overdue]}"
723
+ say "Due Soon: #{stats[:due_soon]}"
724
+
725
+ say "\nPriority:".blue
726
+ say "High: #{stats[:high_priority]}"
727
+ say "Medium: #{stats[:medium_priority]}"
728
+ say "Low: #{stats[:low_priority]}"
729
+
730
+ if stats[:total] > 0
731
+ say "\nStatus Percentages:".blue
732
+ say "Todo: #{percentage(stats[:todo], stats[:total])}%"
733
+ say "In Progress: #{percentage(stats[:in_progress], stats[:total])}%"
734
+ say "Done: #{percentage(stats[:done], stats[:total])}%"
735
+ say "Archived: #{percentage(stats[:archived], stats[:total])}%"
736
+ end
737
+ end
738
+
739
+ def display_global_stats
740
+ notebooks = Notebook.all
741
+
742
+ if notebooks.empty?
743
+ say "No notebooks found".yellow
744
+ return
745
+ end
746
+
747
+ total_tasks = 0
748
+ total_stats = { todo: 0, in_progress: 0, done: 0, archived: 0,
749
+ overdue: 0, due_soon: 0,
750
+ high_priority: 0, medium_priority: 0, low_priority: 0 }
751
+
752
+ notebooks.each do |notebook|
753
+ stats = notebook.statistics
754
+ total_tasks += stats[:total]
755
+
756
+ total_stats[:todo] += stats[:todo]
757
+ total_stats[:in_progress] += stats[:in_progress]
758
+ total_stats[:done] += stats[:done]
759
+ total_stats[:archived] += stats[:archived]
760
+ total_stats[:overdue] += stats[:overdue]
761
+ total_stats[:due_soon] += stats[:due_soon]
762
+ total_stats[:high_priority] += stats[:high_priority]
763
+ total_stats[:medium_priority] += stats[:medium_priority]
764
+ total_stats[:low_priority] += stats[:low_priority]
765
+ end
766
+
767
+ say "\nGlobal Statistics across #{notebooks.count} notebooks:".green
768
+ say "Total Tasks: #{total_tasks}"
769
+
770
+ if total_tasks > 0
771
+ say "\nTask Counts:".blue
772
+ say "Todo: #{total_stats[:todo]} (#{percentage(total_stats[:todo], total_tasks)}%)"
773
+ say "In Progress: #{total_stats[:in_progress]} (#{percentage(total_stats[:in_progress], total_tasks)}%)"
774
+ say "Done: #{total_stats[:done]} (#{percentage(total_stats[:done], total_tasks)}%)"
775
+ say "Archived: #{total_stats[:archived]} (#{percentage(total_stats[:archived], total_tasks)}%)"
776
+
777
+ say "\nDue Dates:".blue
778
+ say "Overdue: #{total_stats[:overdue]} (#{percentage(total_stats[:overdue], total_tasks)}%)"
779
+ say "Due Soon: #{total_stats[:due_soon]} (#{percentage(total_stats[:due_soon], total_tasks)}%)"
780
+
781
+ say "\nPriority:".blue
782
+ say "High: #{total_stats[:high_priority]} (#{percentage(total_stats[:high_priority], total_tasks)}%)"
783
+ say "Medium: #{total_stats[:medium_priority]} (#{percentage(total_stats[:medium_priority], total_tasks)}%)"
784
+ say "Low: #{total_stats[:low_priority]} (#{percentage(total_stats[:low_priority], total_tasks)}%)"
785
+ end
786
+
787
+ # Display top notebooks by task count
788
+ table = TTY::Table.new(
789
+ header: ["Notebook", "Tasks", "% of Total"],
790
+ rows: notebooks.sort_by { |n| -n.tasks.count }.first(5).map do |n|
791
+ [n.name, n.tasks.count, percentage(n.tasks.count, total_tasks)]
792
+ end
793
+ )
794
+
795
+ say "\nTop Notebooks:".blue
796
+ puts table.render(:ascii)
797
+ end
798
+
799
+ def percentage(part, total)
800
+ return 0 if total == 0
801
+
802
+ ((part.to_f / total) * 100).round(1)
803
+ end
804
+
805
+ def parse_due_date(date_string)
806
+ Time.parse(date_string)
807
+ rescue ArgumentError
808
+ say "Invalid date format. Use YYYY-MM-DD HH:MM format.".red
809
+ nil
810
+ end
811
+
812
+ def format_status(status)
813
+ case status
814
+ when "todo" then "Todo".yellow
815
+ when "in_progress" then "In Progress".blue
816
+ when "done" then "Done".green
817
+ when "archived" then "Archived".gray
818
+ else status
819
+ end
820
+ end
821
+
822
+ def format_priority(priority)
823
+ return nil unless priority
824
+
825
+ case priority.downcase
826
+ when "high" then "High".red
827
+ when "medium" then "Medium".yellow
828
+ when "low" then "Low".green
829
+ else priority
830
+ end
831
+ end
832
+
833
+ def format_due_date(due_date)
834
+ return "No due date" unless due_date
835
+
836
+ if due_date < Time.now && due_date > Time.now - 24 * 60 * 60
837
+ "Today #{due_date.strftime("%H:%M")}".red
838
+ elsif due_date < Time.now
839
+ "Overdue #{due_date.strftime("%Y-%m-%d %H:%M")}".red
840
+ elsif due_date < Time.now + 24 * 60 * 60
841
+ "Today #{due_date.strftime("%H:%M")}".yellow
842
+ else
843
+ due_date.strftime("%Y-%m-%d %H:%M")
844
+ end
845
+ end
846
+
847
+ def truncate_text(text, length = 30)
848
+ return nil unless text
849
+
850
+ text.length > length ? "#{text[0...length]}..." : text
851
+ end
852
+ end
853
+ end