ruby_todo 0.4.1 → 1.0.1
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/.env.template +2 -0
- data/.rspec +1 -0
- data/CHANGELOG.md +10 -0
- data/README.md +56 -72
- data/ai_assistant_implementation.md +611 -0
- data/db/migrate/20240328_add_is_default_to_notebooks.rb +10 -0
- data/delete_notebooks.rb +20 -0
- data/implementation_steps.md +130 -0
- data/lib/ruby_todo/ai_assistant/common_query_handler.rb +378 -0
- data/lib/ruby_todo/ai_assistant/configuration_management.rb +27 -0
- data/lib/ruby_todo/ai_assistant/openai_integration.rb +333 -0
- data/lib/ruby_todo/ai_assistant/task_creation.rb +86 -0
- data/lib/ruby_todo/ai_assistant/task_management.rb +327 -0
- data/lib/ruby_todo/ai_assistant/task_search.rb +362 -0
- data/lib/ruby_todo/cli.rb +296 -146
- data/lib/ruby_todo/commands/ai_assistant.rb +449 -0
- data/lib/ruby_todo/database.rb +58 -84
- data/lib/ruby_todo/models/notebook.rb +44 -10
- data/lib/ruby_todo/version.rb +1 -1
- data/progress_ai_test.md +126 -0
- data/protectors_tasks.json +159 -0
- data/test_ai_assistant.rb +55 -0
- data/test_migration.rb +55 -0
- metadata +46 -1
@@ -0,0 +1,449 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "thor"
|
4
|
+
require "json"
|
5
|
+
require "openai"
|
6
|
+
require "dotenv/load"
|
7
|
+
|
8
|
+
require_relative "../ai_assistant/task_search"
|
9
|
+
require_relative "../ai_assistant/task_management"
|
10
|
+
require_relative "../ai_assistant/openai_integration"
|
11
|
+
require_relative "../ai_assistant/configuration_management"
|
12
|
+
require_relative "../ai_assistant/common_query_handler"
|
13
|
+
|
14
|
+
module RubyTodo
|
15
|
+
class AIAssistantCommand < Thor
|
16
|
+
include TaskManagement
|
17
|
+
include OpenAIIntegration
|
18
|
+
include ConfigurationManagement
|
19
|
+
include CommonQueryHandler
|
20
|
+
|
21
|
+
desc "ai:ask [PROMPT]", "Ask the AI assistant to perform tasks using natural language"
|
22
|
+
method_option :api_key, type: :string, desc: "OpenAI API key"
|
23
|
+
method_option :verbose, type: :boolean, default: false, desc: "Show detailed response"
|
24
|
+
def ask(*prompt_args)
|
25
|
+
prompt = prompt_args.join(" ")
|
26
|
+
validate_prompt(prompt)
|
27
|
+
say "\n=== Starting AI Assistant with prompt: '#{prompt}' ===" if options[:verbose]
|
28
|
+
|
29
|
+
# Direct handling for common queries
|
30
|
+
return if handle_common_query(prompt)
|
31
|
+
|
32
|
+
process_ai_query(prompt)
|
33
|
+
end
|
34
|
+
|
35
|
+
desc "ai:configure", "Configure the AI assistant settings"
|
36
|
+
def configure
|
37
|
+
prompt = TTY::Prompt.new
|
38
|
+
api_key = prompt.mask("Enter your OpenAI API key:")
|
39
|
+
save_config("openai", api_key)
|
40
|
+
say "Configuration saved successfully!".green
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.banner(command, _namespace = nil, _subcommand: false)
|
44
|
+
"#{basename} #{command.name}"
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.exit_on_failure?
|
48
|
+
true
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def process_ai_query(prompt)
|
54
|
+
api_key = fetch_api_key
|
55
|
+
say "\nAPI key loaded successfully" if options[:verbose]
|
56
|
+
|
57
|
+
context = build_context
|
58
|
+
say "\nInitial context built" if options[:verbose]
|
59
|
+
|
60
|
+
# Process based on query type
|
61
|
+
process_query_by_type(prompt, context)
|
62
|
+
|
63
|
+
# Get AI response for commands and explanation
|
64
|
+
say "\n=== Querying OpenAI ===" if options[:verbose]
|
65
|
+
response = query_openai(prompt, context, api_key)
|
66
|
+
say "\nOpenAI Response received" if options[:verbose]
|
67
|
+
|
68
|
+
# Execute actions based on response
|
69
|
+
execute_actions(response, context)
|
70
|
+
end
|
71
|
+
|
72
|
+
def process_query_by_type(prompt, context)
|
73
|
+
if should_handle_task_movement?(prompt)
|
74
|
+
# Handle task movement and build context
|
75
|
+
say "\n=== Processing Task Movement Request ===" if options[:verbose]
|
76
|
+
handle_task_request(prompt, context)
|
77
|
+
elsif options[:verbose]
|
78
|
+
say "\n=== Processing Non-Movement Request ==="
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def execute_actions(response, context)
|
83
|
+
# If we have tasks to move, do it now
|
84
|
+
if context[:matching_tasks]&.any? && context[:target_status]
|
85
|
+
say "\n=== Moving Tasks ===" if options[:verbose]
|
86
|
+
move_tasks_to_status(context[:matching_tasks], context[:target_status])
|
87
|
+
end
|
88
|
+
|
89
|
+
# Execute any additional commands from the AI
|
90
|
+
if response && response["commands"]
|
91
|
+
say "\n=== Executing Additional Commands ===" if options[:verbose]
|
92
|
+
execute_commands(response)
|
93
|
+
end
|
94
|
+
|
95
|
+
# Display the explanation from the AI
|
96
|
+
if response && response["explanation"]
|
97
|
+
say "\n=== AI Explanation ===" if options[:verbose]
|
98
|
+
say "\n#{response["explanation"]}"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def validate_prompt(prompt)
|
103
|
+
return if prompt && !prompt.empty?
|
104
|
+
|
105
|
+
say "Please provide a prompt for the AI assistant".red
|
106
|
+
raise ArgumentError, "Empty prompt"
|
107
|
+
end
|
108
|
+
|
109
|
+
def fetch_api_key
|
110
|
+
api_key = options[:api_key] || ENV["OPENAI_API_KEY"] || load_api_key_from_config
|
111
|
+
return api_key if api_key
|
112
|
+
|
113
|
+
say "No API key found. Please provide an API key using --api-key or set OPENAI_API_KEY environment variable".red
|
114
|
+
raise ArgumentError, "No API key found"
|
115
|
+
end
|
116
|
+
|
117
|
+
def build_context
|
118
|
+
{ matching_tasks: [] }
|
119
|
+
end
|
120
|
+
|
121
|
+
def execute_commands(response)
|
122
|
+
return unless response["commands"].is_a?(Array)
|
123
|
+
|
124
|
+
response["commands"].each do |command|
|
125
|
+
process_command(command)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def process_command(command)
|
130
|
+
say "\nExecuting command: #{command}".blue if options[:verbose]
|
131
|
+
|
132
|
+
begin
|
133
|
+
# Skip if empty or nil
|
134
|
+
return if command.nil? || command.strip.empty?
|
135
|
+
|
136
|
+
# Ensure command starts with ruby_todo
|
137
|
+
return unless command.start_with?("ruby_todo")
|
138
|
+
|
139
|
+
# Process and execute the command
|
140
|
+
process_ruby_todo_command(command)
|
141
|
+
rescue StandardError => e
|
142
|
+
say "Error executing command: #{e.message}".red
|
143
|
+
say e.backtrace.join("\n").red if options[:verbose]
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
def process_ruby_todo_command(command)
|
148
|
+
# Remove the ruby_todo prefix
|
149
|
+
cmd_without_prefix = command.sub(/^ruby_todo\s+/, "")
|
150
|
+
say "\nCommand without prefix: '#{cmd_without_prefix}'".blue if options[:verbose]
|
151
|
+
|
152
|
+
# Convert underscores to colons for all task commands
|
153
|
+
if cmd_without_prefix =~ /^task_\w+/
|
154
|
+
cmd_without_prefix = cmd_without_prefix.sub(/^task_(\w+)/, 'task:\1')
|
155
|
+
say "\nConverted underscores to colons: '#{cmd_without_prefix}'".blue if options[:verbose]
|
156
|
+
end
|
157
|
+
|
158
|
+
# Convert underscores to colons for notebook commands
|
159
|
+
if cmd_without_prefix =~ /^notebook_\w+/
|
160
|
+
cmd_without_prefix = cmd_without_prefix.sub(/^notebook_(\w+)/, 'notebook:\1')
|
161
|
+
say "\nConverted underscores to colons: '#{cmd_without_prefix}'".blue if options[:verbose]
|
162
|
+
end
|
163
|
+
|
164
|
+
# Convert underscores to colons for template commands
|
165
|
+
if cmd_without_prefix =~ /^template_\w+/
|
166
|
+
cmd_without_prefix = cmd_without_prefix.sub(/^template_(\w+)/, 'template:\1')
|
167
|
+
say "\nConverted underscores to colons: '#{cmd_without_prefix}'".blue if options[:verbose]
|
168
|
+
end
|
169
|
+
|
170
|
+
# Process different command types
|
171
|
+
if cmd_without_prefix.start_with?("task:list")
|
172
|
+
execute_task_list_command(cmd_without_prefix)
|
173
|
+
elsif cmd_without_prefix.start_with?("task:search")
|
174
|
+
execute_task_search_command(cmd_without_prefix)
|
175
|
+
elsif cmd_without_prefix.start_with?("task:move")
|
176
|
+
execute_task_move_command(cmd_without_prefix)
|
177
|
+
else
|
178
|
+
execute_other_command(cmd_without_prefix)
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
def execute_task_list_command(cmd_without_prefix)
|
183
|
+
parts = cmd_without_prefix.split(/\s+/)
|
184
|
+
say "\nSplit task:list command into parts: #{parts.inspect}".blue if options[:verbose]
|
185
|
+
|
186
|
+
if parts.size >= 2
|
187
|
+
execute_task_list_with_notebook(parts)
|
188
|
+
elsif Notebook.default_notebook
|
189
|
+
execute_task_list_with_default_notebook(parts)
|
190
|
+
else
|
191
|
+
say "\nNo notebook specified for task:list command".yellow
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
def execute_task_list_with_notebook(parts)
|
196
|
+
notebook_name = parts[1]
|
197
|
+
# Extract any options
|
198
|
+
options_args = []
|
199
|
+
parts[2..].each do |part|
|
200
|
+
options_args << part if part.start_with?("--")
|
201
|
+
end
|
202
|
+
|
203
|
+
# Execute the task list command with the notebook name and any options
|
204
|
+
cli_args = ["task:list", notebook_name] + options_args
|
205
|
+
say "\nRunning CLI with args: #{cli_args.inspect}".blue if options[:verbose]
|
206
|
+
RubyTodo::CLI.start(cli_args)
|
207
|
+
end
|
208
|
+
|
209
|
+
def execute_task_list_with_default_notebook(parts)
|
210
|
+
cli_args = ["task:list", Notebook.default_notebook.name]
|
211
|
+
if parts.size > 1 && parts[1].start_with?("--")
|
212
|
+
cli_args << parts[1]
|
213
|
+
end
|
214
|
+
say "\nUsing default notebook for task:list with args: #{cli_args.inspect}".blue if options[:verbose]
|
215
|
+
RubyTodo::CLI.start(cli_args)
|
216
|
+
end
|
217
|
+
|
218
|
+
def execute_task_search_command(cmd_without_prefix)
|
219
|
+
parts = cmd_without_prefix.split(/\s+/, 2) # Split into command and search term
|
220
|
+
|
221
|
+
if parts.size >= 2
|
222
|
+
# Pass the entire search term as a single argument, not individual words
|
223
|
+
cli_args = ["task:search", parts[1]]
|
224
|
+
say "\nRunning CLI with args: #{cli_args.inspect}".blue if options[:verbose]
|
225
|
+
RubyTodo::CLI.start(cli_args)
|
226
|
+
else
|
227
|
+
say "\nNo search term provided for task:search command".yellow
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
def execute_task_move_command(cmd_without_prefix)
|
232
|
+
parts = cmd_without_prefix.split(/\s+/)
|
233
|
+
|
234
|
+
# Need at least task:move NOTEBOOK TASK_ID STATUS
|
235
|
+
if parts.size >= 4
|
236
|
+
notebook_name = parts[1]
|
237
|
+
task_id = parts[2]
|
238
|
+
status = parts[3]
|
239
|
+
|
240
|
+
cli_args = ["task:move", notebook_name, task_id, status]
|
241
|
+
say "\nRunning CLI with args: #{cli_args.inspect}".blue if options[:verbose]
|
242
|
+
RubyTodo::CLI.start(cli_args)
|
243
|
+
else
|
244
|
+
say "\nInvalid task:move command format. Need NOTEBOOK, TASK_ID, and STATUS".yellow
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
def execute_other_command(cmd_without_prefix)
|
249
|
+
# Process all other commands
|
250
|
+
cli_args = cmd_without_prefix.split(/\s+/)
|
251
|
+
say "\nRunning CLI with args: #{cli_args.inspect}".blue if options[:verbose]
|
252
|
+
RubyTodo::CLI.start(cli_args)
|
253
|
+
end
|
254
|
+
|
255
|
+
def handle_common_query(prompt)
|
256
|
+
prompt_lower = prompt.downcase
|
257
|
+
|
258
|
+
# Check for different types of common queries
|
259
|
+
return handle_task_creation(prompt, prompt_lower) if task_creation_query?(prompt_lower)
|
260
|
+
return handle_priority_tasks(prompt_lower, "high") if high_priority_query?(prompt_lower)
|
261
|
+
return handle_priority_tasks(prompt_lower, "medium") if medium_priority_query?(prompt_lower)
|
262
|
+
return handle_statistics(prompt_lower) if statistics_query?(prompt_lower)
|
263
|
+
return handle_status_tasks(prompt_lower) if status_tasks_query?(prompt_lower)
|
264
|
+
return handle_notebook_listing(prompt_lower) if notebook_listing_query?(prompt_lower)
|
265
|
+
|
266
|
+
# Not a common query
|
267
|
+
false
|
268
|
+
end
|
269
|
+
|
270
|
+
def task_creation_query?(prompt_lower)
|
271
|
+
(prompt_lower.include?("create") || prompt_lower.include?("add")) &&
|
272
|
+
(prompt_lower.include?("task") || prompt_lower.include?("todo"))
|
273
|
+
end
|
274
|
+
|
275
|
+
def high_priority_query?(prompt_lower)
|
276
|
+
prompt_lower.include?("high priority") ||
|
277
|
+
(prompt_lower.include?("priority") && prompt_lower.include?("high"))
|
278
|
+
end
|
279
|
+
|
280
|
+
def medium_priority_query?(prompt_lower)
|
281
|
+
prompt_lower.include?("medium priority") ||
|
282
|
+
(prompt_lower.include?("priority") && prompt_lower.include?("medium"))
|
283
|
+
end
|
284
|
+
|
285
|
+
def statistics_query?(prompt_lower)
|
286
|
+
(prompt_lower.include?("statistics") || prompt_lower.include?("stats")) &&
|
287
|
+
(prompt_lower.include?("notebook") || prompt_lower.include?("tasks"))
|
288
|
+
end
|
289
|
+
|
290
|
+
def status_tasks_query?(prompt_lower)
|
291
|
+
statuses = { "todo" => "todo", "in progress" => "in_progress", "done" => "done", "archived" => "archived" }
|
292
|
+
|
293
|
+
statuses.keys.any? do |name|
|
294
|
+
prompt_lower.include?("#{name} tasks") || prompt_lower.include?("tasks in #{name}")
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
def notebook_listing_query?(prompt_lower)
|
299
|
+
prompt_lower.include?("list notebooks") ||
|
300
|
+
prompt_lower.include?("show notebooks") ||
|
301
|
+
prompt_lower.include?("all notebooks")
|
302
|
+
end
|
303
|
+
|
304
|
+
def handle_task_creation(prompt, prompt_lower)
|
305
|
+
say "\n=== Detecting task creation request ===" if options[:verbose]
|
306
|
+
|
307
|
+
title = extract_task_title(prompt)
|
308
|
+
return false unless title
|
309
|
+
|
310
|
+
notebook_name = determine_notebook_name(prompt_lower)
|
311
|
+
return false unless notebook_name
|
312
|
+
|
313
|
+
priority = determine_priority(prompt_lower)
|
314
|
+
|
315
|
+
create_task(notebook_name, title, priority)
|
316
|
+
true
|
317
|
+
end
|
318
|
+
|
319
|
+
def extract_task_title(prompt)
|
320
|
+
# Try to extract title from quotes first
|
321
|
+
title_match = prompt.match(/'([^']+)'|"([^"]+)"/)
|
322
|
+
|
323
|
+
if title_match
|
324
|
+
title_match[1] || title_match[2]
|
325
|
+
else
|
326
|
+
# If no quoted title found, try extracting from the prompt
|
327
|
+
extract_title_from_text(prompt)
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
def extract_title_from_text(prompt)
|
332
|
+
potential_title = prompt
|
333
|
+
phrases_to_remove = [
|
334
|
+
"create a task", "create task", "add a task", "add task",
|
335
|
+
"called", "named", "with", "priority", "high", "medium", "low",
|
336
|
+
"in", "notebook"
|
337
|
+
]
|
338
|
+
|
339
|
+
phrases_to_remove.each do |phrase|
|
340
|
+
potential_title = potential_title.gsub(/#{phrase}/i, " ")
|
341
|
+
end
|
342
|
+
|
343
|
+
result = potential_title.strip
|
344
|
+
result.empty? ? nil : result
|
345
|
+
end
|
346
|
+
|
347
|
+
def determine_notebook_name(prompt_lower)
|
348
|
+
return nil unless Notebook.default_notebook
|
349
|
+
|
350
|
+
notebook_name = Notebook.default_notebook.name
|
351
|
+
|
352
|
+
# Try to extract a specific notebook name from the prompt
|
353
|
+
Notebook.all.each do |notebook|
|
354
|
+
if prompt_lower.include?(notebook.name.downcase)
|
355
|
+
notebook_name = notebook.name
|
356
|
+
break
|
357
|
+
end
|
358
|
+
end
|
359
|
+
|
360
|
+
notebook_name
|
361
|
+
end
|
362
|
+
|
363
|
+
def determine_priority(prompt_lower)
|
364
|
+
if prompt_lower.include?("high priority") || prompt_lower.match(/priority.*high/)
|
365
|
+
"high"
|
366
|
+
elsif prompt_lower.include?("medium priority") || prompt_lower.match(/priority.*medium/)
|
367
|
+
"medium"
|
368
|
+
elsif prompt_lower.include?("low priority") || prompt_lower.match(/priority.*low/)
|
369
|
+
"low"
|
370
|
+
end
|
371
|
+
end
|
372
|
+
|
373
|
+
def create_task(notebook_name, title, priority)
|
374
|
+
say "\nCreating task in notebook: #{notebook_name}" if options[:verbose]
|
375
|
+
cli_args = ["task:add", notebook_name, title]
|
376
|
+
|
377
|
+
# Add priority if specified
|
378
|
+
cli_args.push("--priority", priority) if priority
|
379
|
+
|
380
|
+
RubyTodo::CLI.start(cli_args)
|
381
|
+
|
382
|
+
# Create a simple explanation
|
383
|
+
priority_text = priority ? " with #{priority} priority" : ""
|
384
|
+
say "\nCreated task '#{title}'#{priority_text} in the #{notebook_name} notebook"
|
385
|
+
end
|
386
|
+
|
387
|
+
def handle_priority_tasks(_prompt_lower, priority)
|
388
|
+
say "\n=== Detecting #{priority} priority task request ===" if options[:verbose]
|
389
|
+
|
390
|
+
return false unless Notebook.default_notebook
|
391
|
+
|
392
|
+
say "\nListing #{priority} priority tasks from default notebook" if options[:verbose]
|
393
|
+
RubyTodo::CLI.start(["task:list", Notebook.default_notebook.name, "--priority", priority])
|
394
|
+
|
395
|
+
# Create a simple explanation
|
396
|
+
say "\nListing all #{priority} priority tasks in the #{Notebook.default_notebook.name} notebook"
|
397
|
+
true
|
398
|
+
end
|
399
|
+
|
400
|
+
def handle_statistics(prompt_lower)
|
401
|
+
say "\n=== Detecting statistics request ===" if options[:verbose]
|
402
|
+
|
403
|
+
notebook_name = determine_notebook_name(prompt_lower)
|
404
|
+
|
405
|
+
if notebook_name
|
406
|
+
say "\nShowing statistics for notebook: #{notebook_name}" if options[:verbose]
|
407
|
+
RubyTodo::CLI.start(["stats", notebook_name])
|
408
|
+
say "\nDisplaying statistics for the #{notebook_name} notebook"
|
409
|
+
else
|
410
|
+
# Show global stats if no default notebook
|
411
|
+
say "\nShowing global statistics" if options[:verbose]
|
412
|
+
RubyTodo::CLI.start(["stats"])
|
413
|
+
say "\nDisplaying global statistics for all notebooks"
|
414
|
+
end
|
415
|
+
|
416
|
+
true
|
417
|
+
end
|
418
|
+
|
419
|
+
def handle_status_tasks(prompt_lower)
|
420
|
+
statuses = { "todo" => "todo", "in progress" => "in_progress", "done" => "done", "archived" => "archived" }
|
421
|
+
|
422
|
+
statuses.each do |name, value|
|
423
|
+
next unless prompt_lower.include?("#{name} tasks") || prompt_lower.include?("tasks in #{name}")
|
424
|
+
|
425
|
+
say "\n=== Detecting #{name} task listing request ===" if options[:verbose]
|
426
|
+
|
427
|
+
return false unless Notebook.default_notebook
|
428
|
+
|
429
|
+
say "\nListing #{name} tasks from default notebook" if options[:verbose]
|
430
|
+
RubyTodo::CLI.start(["task:list", Notebook.default_notebook.name, "--status", value])
|
431
|
+
|
432
|
+
# Create a simple explanation
|
433
|
+
say "\nListing all #{name} tasks in the #{Notebook.default_notebook.name} notebook"
|
434
|
+
return true
|
435
|
+
end
|
436
|
+
|
437
|
+
false
|
438
|
+
end
|
439
|
+
|
440
|
+
def handle_notebook_listing(_prompt_lower)
|
441
|
+
say "\n=== Detecting notebook listing request ===" if options[:verbose]
|
442
|
+
RubyTodo::CLI.start(["notebook:list"])
|
443
|
+
|
444
|
+
# Create a simple explanation
|
445
|
+
say "\nListing all available notebooks"
|
446
|
+
true
|
447
|
+
end
|
448
|
+
end
|
449
|
+
end
|
data/lib/ruby_todo/database.rb
CHANGED
@@ -10,112 +10,86 @@ module RubyTodo
|
|
10
10
|
def setup
|
11
11
|
return if ActiveRecord::Base.connected?
|
12
12
|
|
13
|
-
|
14
|
-
|
13
|
+
ensure_database_directory
|
14
|
+
establish_connection
|
15
|
+
create_tables
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
15
19
|
|
20
|
+
def ensure_database_directory
|
21
|
+
db_dir = File.expand_path("~/.ruby_todo")
|
22
|
+
FileUtils.mkdir_p(db_dir)
|
23
|
+
end
|
24
|
+
|
25
|
+
def establish_connection
|
26
|
+
db_path = File.expand_path("~/.ruby_todo/ruby_todo.db")
|
16
27
|
ActiveRecord::Base.establish_connection(
|
17
28
|
adapter: "sqlite3",
|
18
29
|
database: db_path
|
19
30
|
)
|
20
|
-
|
21
|
-
create_tables unless tables_exist?
|
22
|
-
check_schema_version
|
23
|
-
end
|
24
|
-
|
25
|
-
private
|
26
|
-
|
27
|
-
def tables_exist?
|
28
|
-
ActiveRecord::Base.connection.tables.include?("notebooks") &&
|
29
|
-
ActiveRecord::Base.connection.tables.include?("tasks") &&
|
30
|
-
ActiveRecord::Base.connection.tables.include?("schema_migrations")
|
31
31
|
end
|
32
32
|
|
33
33
|
def create_tables
|
34
|
+
connection = ActiveRecord::Base.connection
|
35
|
+
|
34
36
|
ActiveRecord::Schema.define do
|
35
|
-
|
36
|
-
|
37
|
-
|
37
|
+
unless connection.table_exists?(:notebooks)
|
38
|
+
create_table :notebooks do |t|
|
39
|
+
t.string :name, null: false
|
40
|
+
t.boolean :is_default, default: false
|
41
|
+
t.timestamps
|
42
|
+
end
|
38
43
|
end
|
39
44
|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
45
|
+
unless connection.table_exists?(:tasks)
|
46
|
+
create_table :tasks do |t|
|
47
|
+
t.references :notebook, null: false
|
48
|
+
t.string :title, null: false
|
49
|
+
t.text :description
|
50
|
+
t.string :status, default: "todo"
|
51
|
+
t.datetime :due_date
|
52
|
+
t.string :priority
|
53
|
+
t.string :tags
|
54
|
+
t.timestamps
|
55
|
+
end
|
49
56
|
end
|
50
57
|
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
58
|
+
unless connection.table_exists?(:templates)
|
59
|
+
create_table :templates do |t|
|
60
|
+
t.string :name, null: false
|
61
|
+
t.string :title_pattern, null: false
|
62
|
+
t.text :description_pattern
|
63
|
+
t.string :priority
|
64
|
+
t.string :tags_pattern
|
65
|
+
t.string :due_date_offset
|
66
|
+
t.references :notebook
|
67
|
+
t.timestamps
|
68
|
+
end
|
60
69
|
end
|
61
70
|
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
add_index :templates, :name, unique: true
|
66
|
-
|
67
|
-
create_table :schema_migrations do |t|
|
68
|
-
t.integer :version, null: false
|
71
|
+
# Add indexes if they don't exist
|
72
|
+
unless connection.index_exists?(:tasks, %i[notebook_id status])
|
73
|
+
add_index :tasks, %i[notebook_id status]
|
69
74
|
end
|
70
|
-
end
|
71
75
|
|
72
|
-
|
73
|
-
|
74
|
-
|
76
|
+
unless connection.index_exists?(:tasks, :priority)
|
77
|
+
add_index :tasks, :priority
|
78
|
+
end
|
75
79
|
|
76
|
-
|
77
|
-
|
78
|
-
|
80
|
+
unless connection.index_exists?(:tasks, :due_date)
|
81
|
+
add_index :tasks, :due_date
|
82
|
+
end
|
79
83
|
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
end
|
84
|
+
unless connection.index_exists?(:templates, :name)
|
85
|
+
add_index :templates, :name, unique: true
|
86
|
+
end
|
84
87
|
|
85
|
-
|
86
|
-
|
88
|
+
unless connection.index_exists?(:notebooks, :is_default)
|
89
|
+
add_index :notebooks, :is_default
|
90
|
+
end
|
87
91
|
end
|
88
92
|
end
|
89
|
-
|
90
|
-
def upgrade_to_version_1
|
91
|
-
ActiveRecord::Base.connection.execute(<<-SQL)
|
92
|
-
ALTER TABLE tasks ADD COLUMN priority STRING;
|
93
|
-
ALTER TABLE tasks ADD COLUMN tags STRING;
|
94
|
-
SQL
|
95
|
-
|
96
|
-
ActiveRecord::Base.connection.execute("UPDATE schema_migrations SET version = 1")
|
97
|
-
end
|
98
|
-
|
99
|
-
def upgrade_to_version_2
|
100
|
-
ActiveRecord::Base.connection.execute(<<-SQL)
|
101
|
-
CREATE TABLE templates (
|
102
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
103
|
-
notebook_id INTEGER,
|
104
|
-
name VARCHAR NOT NULL,
|
105
|
-
title_pattern VARCHAR NOT NULL,
|
106
|
-
description_pattern TEXT,
|
107
|
-
tags_pattern VARCHAR,
|
108
|
-
priority VARCHAR,
|
109
|
-
due_date_offset VARCHAR,
|
110
|
-
created_at DATETIME NOT NULL,
|
111
|
-
updated_at DATETIME NOT NULL,
|
112
|
-
FOREIGN KEY (notebook_id) REFERENCES notebooks(id)
|
113
|
-
);
|
114
|
-
CREATE UNIQUE INDEX index_templates_on_name ON templates (name);
|
115
|
-
SQL
|
116
|
-
|
117
|
-
ActiveRecord::Base.connection.execute("UPDATE schema_migrations SET version = 2")
|
118
|
-
end
|
119
93
|
end
|
120
94
|
end
|
121
95
|
end
|
@@ -5,8 +5,30 @@ require "active_record"
|
|
5
5
|
module RubyTodo
|
6
6
|
class Notebook < ActiveRecord::Base
|
7
7
|
has_many :tasks, dependent: :destroy
|
8
|
+
has_many :templates
|
8
9
|
|
9
|
-
validates :name, presence: true
|
10
|
+
validates :name, presence: true
|
11
|
+
validates :name, uniqueness: true
|
12
|
+
|
13
|
+
scope :default, -> { where(is_default: true).first }
|
14
|
+
|
15
|
+
before_create :set_default_if_first
|
16
|
+
before_save :ensure_only_one_default
|
17
|
+
|
18
|
+
def self.default_notebook
|
19
|
+
find_by(is_default: true)
|
20
|
+
end
|
21
|
+
|
22
|
+
def make_default!
|
23
|
+
transaction do
|
24
|
+
Notebook.where.not(id: id).update_all(is_default: false)
|
25
|
+
update!(is_default: true)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def is_default?
|
30
|
+
is_default
|
31
|
+
end
|
10
32
|
|
11
33
|
def tasks_by_status(status)
|
12
34
|
tasks.where(status: status)
|
@@ -59,16 +81,28 @@ module RubyTodo
|
|
59
81
|
def statistics
|
60
82
|
{
|
61
83
|
total: tasks.count,
|
62
|
-
todo:
|
63
|
-
in_progress:
|
64
|
-
done:
|
65
|
-
archived:
|
66
|
-
overdue:
|
67
|
-
due_soon:
|
68
|
-
high_priority:
|
69
|
-
medium_priority:
|
70
|
-
low_priority:
|
84
|
+
todo: tasks.where(status: "todo").count,
|
85
|
+
in_progress: tasks.where(status: "in_progress").count,
|
86
|
+
done: tasks.where(status: "done").count,
|
87
|
+
archived: tasks.where(status: "archived").count,
|
88
|
+
overdue: tasks.select(&:overdue?).count,
|
89
|
+
due_soon: tasks.select(&:due_soon?).count,
|
90
|
+
high_priority: tasks.where(priority: "high").count,
|
91
|
+
medium_priority: tasks.where(priority: "medium").count,
|
92
|
+
low_priority: tasks.where(priority: "low").count
|
71
93
|
}
|
72
94
|
end
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
def set_default_if_first
|
99
|
+
self.is_default = true if Notebook.count.zero?
|
100
|
+
end
|
101
|
+
|
102
|
+
def ensure_only_one_default
|
103
|
+
return unless is_default_changed? && is_default?
|
104
|
+
|
105
|
+
Notebook.where.not(id: id).update_all(is_default: false)
|
106
|
+
end
|
73
107
|
end
|
74
108
|
end
|
data/lib/ruby_todo/version.rb
CHANGED