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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -54
- data/PRD.md +264 -0
- data/PROGRESS.md +273 -0
- data/README.md +94 -1
- data/exe/ruby_todo +6 -0
- data/lib/ruby_todo/cli.rb +853 -0
- data/lib/ruby_todo/database.rb +121 -0
- data/lib/ruby_todo/models/notebook.rb +74 -0
- data/lib/ruby_todo/models/task.rb +79 -0
- data/lib/ruby_todo/models/template.rb +84 -0
- data/lib/ruby_todo/version.rb +1 -1
- metadata +11 -3
- data/ruby_todo.gemspec +0 -52
@@ -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
|