tasku 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a32bab956724e7408c6163998e3723591bcd8b29d47468432cc38e21384e848c
4
+ data.tar.gz: fd6d15eb4ae3d48f5cf1b4ebfd696d636b2dc652ad9d3846467ae8870c542d56
5
+ SHA512:
6
+ metadata.gz: ac4edabbe7421d15b8c69608be6af274f6f3a0865a78cb228e9a15360ca848b4637b1bc25045ea8a68e4b38ab3d1b48b7b3f1e36c4120d885ca287ba283b3959
7
+ data.tar.gz: 46b1d2c093d9779d87aa07eb96b4f5c1b2c256998789ff8b7adf49e4c8501708ebedf7fb278e3203a0b77a164bdda1e9cadb726bac11c1473ab2537aa38519db
data/exe/tasku ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "tasku"
5
+
6
+ Tasku::CLI::App.start(ARGV)
data/lib/tasku/cli.rb ADDED
@@ -0,0 +1,343 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tasku
4
+ module CLI
5
+ LOGO = <<~LOGO
6
+ _______ _
7
+ |__ __| | |
8
+ | | __ _ ___| | ___ _
9
+ | |/ _` / __| |/ / | | |
10
+ | | (_| \\__ \\ <| |_| |
11
+ |_|\\__,_|___/_|\\_\\\\__,_|
12
+ LOGO
13
+
14
+ TAGLINE = "\u30BF\u30B9\u30AF\u30EA\u30B9\u30C8 \u2014 terminal task manager"
15
+
16
+ class App < Thor
17
+ def self.start(args = ARGV, **opts)
18
+ pastel = Pastel.new
19
+ $stdout.puts ""
20
+ $stdout.puts pastel.bright_cyan(LOGO)
21
+ $stdout.puts pastel.bright_cyan(" #{TAGLINE}")
22
+ $stdout.puts ""
23
+ super
24
+ end
25
+
26
+ class_option :db, type: :string, desc: "Path to SQLite database (default: ~/.tasku/tasks.db)", hide: true
27
+
28
+ desc "add", "Create a new task"
29
+ option :name, type: :string, desc: "Task name", required: false
30
+ option :description, type: :string, desc: "Task description"
31
+ option :project, type: :string, desc: "Project name"
32
+ option :category, type: :string, desc: "Category"
33
+ option :start, type: :string, desc: "Start date (YYYY-MM-DD)"
34
+ option :due, type: :string, desc: "Due date (YYYY-MM-DD)"
35
+ option :model, type: :string, desc: "Model identifier"
36
+ option :priority, type: :string, desc: "Priority: none, low, medium, high, urgent"
37
+ option :status, type: :string, desc: "Status: backlog, todo, in_progress, done, cancelled, archived"
38
+ option :tags, type: :string, desc: "Comma-separated tags"
39
+ option :hours, type: :numeric, desc: "Estimated hours"
40
+ option :interactive, type: :boolean, aliases: "-i", desc: "Interactive mode", default: false
41
+ def add
42
+ attrs = if options[:interactive] || options.values_at(:name, :description, :project, :category).all?(&:nil?)
43
+ interactive_add
44
+ else
45
+ option_add
46
+ end
47
+
48
+ task = Task.create(attrs)
49
+ terminal.render_added(task)
50
+ rescue Sequel::ValidationFailed => e
51
+ abort pastel.red("Validation error: #{e.message}")
52
+ end
53
+
54
+ desc "list", "List all tasks"
55
+ option :status, type: :string, desc: "Filter by status"
56
+ option :priority, type: :string, desc: "Filter by priority"
57
+ option :project, type: :string, desc: "Filter by project"
58
+ option :category, type: :string, desc: "Filter by category"
59
+ option :tags, type: :string, desc: "Filter by tag (comma-separated)"
60
+ option :overdue, type: :boolean, desc: "Show only overdue tasks"
61
+ option :sort, type: :string, desc: "Sort by: name, priority, due, status, created"
62
+ option :order, type: :string, desc: "Order: asc, desc", default: "asc"
63
+ def list
64
+ dataset = Task.dataset
65
+
66
+ dataset = dataset.where(status: options[:status]) if options[:status]
67
+ dataset = dataset.where(priority: options[:priority]) if options[:priority]
68
+ dataset = dataset.where(project: options[:project]) if options[:project]
69
+ dataset = dataset.where(category: options[:category]) if options[:category]
70
+
71
+ if options[:tags]
72
+ tag_filter = options[:tags].split(",").map(&:strip)
73
+ tag_filter.each do |t|
74
+ dataset = dataset.where(Sequel.ilike(:tags, "%#{t}%"))
75
+ end
76
+ end
77
+
78
+ if options[:overdue]
79
+ today = Date.today
80
+ dataset = dataset.where { due_day < today }.exclude(status: %w[done cancelled])
81
+ end
82
+
83
+ sort_col = case options[:sort]
84
+ when "name" then :name
85
+ when "priority" then Sequel.case(Task::VALID_PRIORITIES.each_with_index.to_h, 999, :priority)
86
+ when "due" then :due_day
87
+ when "status" then Sequel.case(Task::VALID_STATUSES.each_with_index.to_h, 999, :status)
88
+ else :created_at
89
+ end
90
+
91
+ order = options[:order] == "desc" ? Sequel.desc(sort_col) : Sequel.asc(sort_col)
92
+ dataset = dataset.order(order)
93
+
94
+ tasks = dataset.all
95
+ terminal.render_list(tasks)
96
+ end
97
+
98
+ desc "show ID", "Show task details"
99
+ def show(id)
100
+ task = find_task(id)
101
+ terminal.render_show(task)
102
+ end
103
+
104
+ desc "edit ID", "Edit a task"
105
+ option :name, type: :string, desc: "Task name"
106
+ option :description, type: :string, desc: "Task description"
107
+ option :project, type: :string, desc: "Project name"
108
+ option :category, type: :string, desc: "Category"
109
+ option :start, type: :string, desc: "Start date (YYYY-MM-DD)"
110
+ option :due, type: :string, desc: "Due date (YYYY-MM-DD)"
111
+ option :model, type: :string, desc: "Model identifier"
112
+ option :priority, type: :string, desc: "Priority: none, low, medium, high, urgent"
113
+ option :status, type: :string, desc: "Status: backlog, todo, in_progress, done, cancelled, archived"
114
+ option :tags, type: :string, desc: "Comma-separated tags"
115
+ option :hours, type: :numeric, desc: "Estimated hours"
116
+ option :clear, type: :string, desc: "Clear a field: description, start, due, model, tags, hours"
117
+ def edit(id)
118
+ task = find_task(id)
119
+
120
+ attrs = option_edit
121
+ if attrs.empty? && !options[:clear]
122
+ puts pastel.yellow("No changes specified. Use --help to see available options.")
123
+ return
124
+ end
125
+
126
+ if options[:clear]
127
+ clear_fields = options[:clear].split(",").map(&:strip)
128
+ clear_map = {
129
+ "description" => :description,
130
+ "start" => :start_day,
131
+ "due" => :due_day,
132
+ "model" => :model_name,
133
+ "tags" => :tags,
134
+ "hours" => :estimated_hours
135
+ }
136
+ clear_fields.each do |f|
137
+ col = clear_map[f]
138
+ if col
139
+ attrs[col] = nil
140
+ else
141
+ puts pastel.yellow("Unknown field to clear: #{f}")
142
+ end
143
+ end
144
+ end
145
+
146
+ task.update(attrs)
147
+ terminal.render_updated(task)
148
+ rescue Sequel::ValidationFailed => e
149
+ abort pastel.red("Validation error: #{e.message}")
150
+ end
151
+
152
+ desc "done ID", "Mark a task as done"
153
+ def done(id)
154
+ task = find_task(id)
155
+ task.update(status: "done")
156
+ terminal.render_updated(task)
157
+ end
158
+
159
+ desc "delete ID", "Delete a task"
160
+ option :force, type: :boolean, aliases: "-f", desc: "Skip confirmation"
161
+ def delete(id)
162
+ task = find_task(id)
163
+
164
+ unless options[:force]
165
+ prompt = TTY::Prompt.new
166
+ confirmed = prompt.yes?(pastel.red("Delete task ##{id} (#{task.name})?"))
167
+ return unless confirmed
168
+ end
169
+
170
+ task.destroy
171
+ terminal.render_deleted(task)
172
+ end
173
+
174
+ desc "stats", "Show task statistics"
175
+ def stats
176
+ tasks = Task.dataset.all
177
+ terminal.render_stats(tasks) if tasks
178
+ end
179
+
180
+ desc "projects", "List all projects"
181
+ def projects
182
+ projects = Task.dataset.select(:project).where(Sequel.~(project: nil)).distinct.order(:project).map(:project)
183
+ if projects.empty?
184
+ puts pastel.yellow(" No projects found.")
185
+ return
186
+ end
187
+
188
+ puts ""
189
+ projects.each do |p|
190
+ count = Task.where(project: p).count
191
+ puts " #{pastel.bold(p)} #{pastel.dim("(#{count} task(s))")}"
192
+ end
193
+ puts ""
194
+ end
195
+
196
+ desc "categories", "List all categories"
197
+ def categories
198
+ categories = Task.dataset.select(:category).where(Sequel.~(category: nil)).distinct.order(:category).map(:category)
199
+ if categories.empty?
200
+ puts pastel.yellow(" No categories found.")
201
+ return
202
+ end
203
+
204
+ puts ""
205
+ categories.each do |c|
206
+ count = Task.where(category: c).count
207
+ puts " #{pastel.bold(c)} #{pastel.dim("(#{count} task(s))")}"
208
+ end
209
+ puts ""
210
+ end
211
+
212
+ desc "version", "Show version"
213
+ def version
214
+ puts "tasku #{Tasku::VERSION}"
215
+ end
216
+
217
+ no_commands do
218
+ def terminal
219
+ @terminal ||= Output::Terminal.new
220
+ end
221
+
222
+ def pastel
223
+ @pastel ||= Pastel.new
224
+ end
225
+
226
+ def find_task(id)
227
+ task = Task[id.to_i]
228
+ abort pastel.red("Task ##{id} not found.") unless task
229
+ task
230
+ end
231
+
232
+ def parse_date(str)
233
+ return if str.nil? || str.strip.empty?
234
+
235
+ Date.parse(str)
236
+ rescue Date::Error
237
+ abort pastel.red("Invalid date: '#{str}'. Use YYYY-MM-DD format.")
238
+ end
239
+
240
+ def interactive_add
241
+ prompt = TTY::Prompt.new
242
+
243
+ name = prompt.ask("Task name:", required: true) do |q|
244
+ q.modify :strip
245
+ end
246
+
247
+ description = prompt.ask("Description:", default: "")
248
+ description = nil if description&.empty?
249
+
250
+ project = prompt.ask("Project:", default: "")
251
+ project = nil if project&.empty?
252
+
253
+ category = prompt.ask("Category:", default: "")
254
+ category = nil if category&.empty?
255
+
256
+ priority = prompt.select("Priority?", %w[none low medium high urgent], default: 1)
257
+ status = prompt.select("Status?", %w[backlog todo in_progress], default: 2)
258
+
259
+ start_day = prompt.ask("Start date (YYYY-MM-DD, optional):", default: "")
260
+ start_day = nil if start_day&.empty?
261
+
262
+ due_day = prompt.ask("Due date (YYYY-MM-DD, optional):", default: "")
263
+ due_day = nil if due_day&.empty?
264
+
265
+ model_name = prompt.ask("Model:", default: "")
266
+ model_name = nil if model_name&.empty?
267
+
268
+ tags = prompt.ask("Tags (comma-separated):", default: "")
269
+ tags = nil if tags&.empty?
270
+
271
+ hours = prompt.ask("Estimated hours:", default: "") do |q|
272
+ q.convert(:float, "")
273
+ end
274
+ hours = nil if hours.is_a?(String) && hours.empty?
275
+
276
+ {
277
+ name: name,
278
+ description: (description unless description&.empty?),
279
+ project: (project unless project&.empty?),
280
+ category: (category unless category&.empty?),
281
+ start_day: parse_date(start_day),
282
+ due_day: parse_date(due_day),
283
+ model_name: (model_name unless model_name&.empty?),
284
+ priority: priority,
285
+ status: status,
286
+ tags: (tags unless tags&.empty?),
287
+ estimated_hours: hours
288
+ }.compact
289
+ end
290
+
291
+ def option_add
292
+ validate_priority!(options[:priority]) if options[:priority]
293
+ validate_status!(options[:status]) if options[:status]
294
+
295
+ {
296
+ name: (options[:name] or abort(pastel.red("Name is required. Use --name or -i for interactive mode."))),
297
+ description: options[:description],
298
+ project: options[:project],
299
+ category: options[:category],
300
+ start_day: parse_date(options[:start]),
301
+ due_day: parse_date(options[:due]),
302
+ model_name: options[:model],
303
+ priority: options[:priority] || "none",
304
+ status: options[:status] || "todo",
305
+ tags: options[:tags],
306
+ estimated_hours: options[:hours]
307
+ }.compact
308
+ end
309
+
310
+ def option_edit
311
+ validate_priority!(options[:priority]) if options[:priority]
312
+ validate_status!(options[:status]) if options[:status]
313
+
314
+ {
315
+ name: options[:name],
316
+ description: options[:description],
317
+ project: options[:project],
318
+ category: options[:category],
319
+ start_day: parse_date(options[:start]),
320
+ due_day: parse_date(options[:due]),
321
+ model_name: options[:model],
322
+ priority: options[:priority],
323
+ status: options[:status],
324
+ tags: options[:tags],
325
+ estimated_hours: options[:hours]
326
+ }.compact
327
+ end
328
+
329
+ def validate_priority!(value)
330
+ return if Task::VALID_PRIORITIES.include?(value)
331
+
332
+ abort pastel.red("Invalid priority '#{value}'. Valid: #{Task::VALID_PRIORITIES.join(', ')}")
333
+ end
334
+
335
+ def validate_status!(value)
336
+ return if Task::VALID_STATUSES.include?(value)
337
+
338
+ abort pastel.red("Invalid status '#{value}'. Valid: #{Task::VALID_STATUSES.join(', ')}")
339
+ end
340
+ end
341
+ end
342
+ end
343
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel"
4
+ require "fileutils"
5
+
6
+ module Tasku
7
+ module Database
8
+ DB_DIR = File.join(Dir.home, ".tasku")
9
+ DB_PATH = File.join(DB_DIR, "tasks.db")
10
+
11
+ def self.connect
12
+ FileUtils.mkdir_p(DB_DIR)
13
+ @db = Sequel.sqlite(DB_PATH)
14
+ Sequel::Model.db = @db
15
+ migrate
16
+ @db
17
+ end
18
+
19
+ def self.db
20
+ @db || connect
21
+ end
22
+
23
+ def self.migrate
24
+ db.create_table? :tasks do
25
+ primary_key :id
26
+ String :name, null: false
27
+ File :description
28
+ String :project
29
+ String :category
30
+ Date :start_day
31
+ Date :due_day
32
+ String :model_name
33
+ String :priority, default: "none"
34
+ String :status, default: "todo"
35
+ String :tags
36
+ Float :estimated_hours
37
+ DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
38
+ DateTime :updated_at, default: Sequel::CURRENT_TIMESTAMP
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Tasku
6
+ module Output
7
+ class Terminal
8
+ PRIORITY_STYLES = {
9
+ "none" => { color: :dim, symbol: " " },
10
+ "low" => { color: :cyan, symbol: "↓" },
11
+ "medium" => { color: :yellow, symbol: "■" },
12
+ "high" => { color: :red, symbol: "▲" },
13
+ "urgent" => { color: :bright_magenta, symbol: "‼" }
14
+ }.freeze
15
+
16
+ STATUS_STYLES = {
17
+ "backlog" => { color: :dim, symbol: "○" },
18
+ "todo" => { color: :blue, symbol: "○" },
19
+ "in_progress" => { color: :yellow, symbol: "◉" },
20
+ "done" => { color: :green, symbol: "✓" },
21
+ "cancelled" => { color: :red, symbol: "✗" },
22
+ "archived" => { color: :dim, symbol: "⊘" }
23
+ }.freeze
24
+
25
+ COL_SEP = " "
26
+
27
+ def initialize
28
+ @pastel = Pastel.new
29
+ @term_width = [terminal_width, 80].min
30
+ end
31
+
32
+ def render_list(tasks)
33
+ if tasks.empty?
34
+ puts @pastel.yellow(" No tasks found.")
35
+ return
36
+ end
37
+
38
+ rows = tasks.map { |t| build_columns(t) }
39
+ col_widths = compute_widths(rows)
40
+ total = col_widths.sum + (COL_SEP.length * (col_widths.length - 1))
41
+
42
+ puts ""
43
+ puts @pastel.dim(" #{"─" * total}")
44
+ rows.each do |cols|
45
+ line = cols.each_with_index.map { |c, i| c.to_s.ljust(col_widths[i]) }.join(COL_SEP)
46
+ puts " #{line}"
47
+ end
48
+ puts @pastel.dim(" #{"─" * total}")
49
+ puts @pastel.dim(" #{tasks.length} task(s) found")
50
+ end
51
+
52
+ def render_show(task)
53
+ puts ""
54
+ puts @pastel.bold(" Task ##{task.id}")
55
+ puts @pastel.dim(" #{"─" * 60}")
56
+
57
+ fields = [
58
+ ["Name", @pastel.bold(task.name)],
59
+ ["Description", task.description ? @pastel.dim(task.description) : @pastel.dim("—")],
60
+ ["Project", task.project || @pastel.dim("—")],
61
+ ["Category", task.category || @pastel.dim("—")],
62
+ ["Priority", priority_tag(task.priority)],
63
+ ["Status", status_tag(task.status)],
64
+ ["Start Day", task.start_day ? task.start_day.to_s : @pastel.dim("—")],
65
+ ["Due Day", due_cell(task)],
66
+ ["Model", task.model_name || @pastel.dim("—")],
67
+ ["Tags", task.tag_list.empty? ? @pastel.dim("—") : task.tag_list.join(", ")],
68
+ ["Est. Hours", task.estimated_hours ? task.estimated_hours.to_s : @pastel.dim("—")],
69
+ ["Created", task.created_at&.strftime("%Y-%m-%d %H:%M") || @pastel.dim("—")],
70
+ ["Updated", task.updated_at&.strftime("%Y-%m-%d %H:%M") || @pastel.dim("—")]
71
+ ]
72
+
73
+ max_label = fields.map { |l, _| l.length }.max
74
+ fields.each do |label, value|
75
+ puts " #{@pastel.dim(label.ljust(max_label))} #{value}"
76
+ end
77
+ puts ""
78
+ end
79
+
80
+ def render_added(task)
81
+ puts @pastel.green(" ✓ Task ##{task.id} created") + " — #{@pastel.bold(task.name)}"
82
+ end
83
+
84
+ def render_updated(task)
85
+ puts @pastel.green(" ✓ Task ##{task.id} updated") + " — #{@pastel.bold(task.name)}"
86
+ end
87
+
88
+ def render_deleted(task)
89
+ puts @pastel.red(" ✗ Task ##{task.id} deleted") + " — #{@pastel.bold(task.name)}"
90
+ end
91
+
92
+ def render_stats(tasks)
93
+ puts ""
94
+ puts @pastel.bold(" Task Statistics")
95
+ puts @pastel.dim(" #{"─" * 60}")
96
+
97
+ total = tasks.length
98
+ by_status = tasks.group_by(&:status).transform_values(&:length)
99
+ by_priority = tasks.group_by(&:priority).transform_values(&:length)
100
+ overdue = tasks.count(&:overdue?)
101
+ projects = tasks.map(&:project).compact.uniq.length
102
+
103
+ puts " #{@pastel.dim("Total tasks:".ljust(20))} #{total}"
104
+ puts " #{@pastel.dim("Overdue:".ljust(20))} #{overdue.positive? ? @pastel.red(overdue.to_s) : @pastel.green("0")}"
105
+ puts " #{@pastel.dim("Projects:".ljust(20))} #{projects}"
106
+ puts ""
107
+
108
+ puts " #{@pastel.dim("By Status:")}"
109
+ Tasku::Task::VALID_STATUSES.each do |s|
110
+ count = by_status[s] || 0
111
+ next if count.zero?
112
+
113
+ puts " #{status_tag(s)} #{@pastel.send(STATUS_STYLES.dig(s, :color) || :dim, count.to_s.rjust(3))}"
114
+ end
115
+
116
+ puts ""
117
+ puts " #{@pastel.dim("By Priority:")}"
118
+ Tasku::Task::VALID_PRIORITIES.each do |p|
119
+ count = by_priority[p] || 0
120
+ next if count.zero?
121
+
122
+ puts " #{priority_tag(p)} #{@pastel.send(PRIORITY_STYLES.dig(p, :color) || :dim, count.to_s.rjust(3))}"
123
+ end
124
+ puts ""
125
+ end
126
+
127
+ private
128
+
129
+ def build_columns(task)
130
+ id_str = @pastel.dim(task.id.to_s.rjust(2))
131
+ name_str = @pastel.bold(task.name)
132
+ proj_str = task.project || @pastel.dim("—")
133
+ prio_str = priority_tag(task.priority)
134
+ stat_str = status_tag(task.status)
135
+ due_str = due_cell(task)
136
+ [id_str, name_str, proj_str, prio_str, stat_str, due_str]
137
+ end
138
+
139
+ def compute_widths(rows)
140
+ raw = rows.map { |cols| cols.map { |c| strip_ansi(c.to_s).length } }
141
+ maxes = raw.transpose.map(&:max)
142
+ sep_total = COL_SEP.length * (maxes.length - 1)
143
+ fixed_cols = maxes[0] + maxes[2] + maxes[3] + maxes[4] + maxes[5]
144
+ available_name = @term_width - fixed_cols - sep_total - 4
145
+ min_name = 20
146
+
147
+ if maxes[1] > available_name || maxes[1] < min_name
148
+ name_width = [available_name, min_name].max
149
+ rows.each_with_index do |cols, _ri|
150
+ raw_str = strip_ansi(cols[1].to_s)
151
+ if raw_str.length > name_width
152
+ cols[1] = "#{raw_str[0..name_width - 2]}#{@pastel.dim("…")}"
153
+ end
154
+ end
155
+ maxes[1] = name_width
156
+ end
157
+
158
+ maxes
159
+ end
160
+
161
+ def strip_ansi(str)
162
+ str.gsub(/\e\[[0-9;]*m/, "")
163
+ end
164
+
165
+ def terminal_width
166
+ IO.console&.winsize&.[](1) || 80
167
+ rescue
168
+ 80
169
+ end
170
+
171
+ def priority_tag(priority)
172
+ style = PRIORITY_STYLES[priority] || PRIORITY_STYLES["none"]
173
+ @pastel.send(style[:color], "#{style[:symbol]} #{priority.capitalize}")
174
+ end
175
+
176
+ def status_tag(status)
177
+ style = STATUS_STYLES[status] || STATUS_STYLES["backlog"]
178
+ label = status.tr("_", " ").capitalize
179
+ @pastel.send(style[:color], "#{style[:symbol]} #{label}")
180
+ end
181
+
182
+ def due_cell(task)
183
+ return @pastel.dim("—") unless task.due_day
184
+
185
+ diff = (task.due_day - Date.today).to_i
186
+ day = task.due_day.strftime("%b %d")
187
+ if task.overdue?
188
+ @pastel.red("#{day} (#{diff.abs}d ago)")
189
+ elsif diff.zero?
190
+ @pastel.yellow("#{day} (today)")
191
+ elsif diff <= 7
192
+ @pastel.yellow("#{day} (+#{diff}d)")
193
+ else
194
+ @pastel.green(day)
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
data/lib/tasku/task.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "database"
4
+
5
+ module Tasku
6
+ class Task < Sequel::Model(:tasks)
7
+ plugin :timestamps, update_on_create: true
8
+
9
+ VALID_PRIORITIES = %w[none low medium high urgent].freeze
10
+ VALID_STATUSES = %w[backlog todo in_progress done cancelled archived].freeze
11
+
12
+ def validate
13
+ super
14
+ errors.add(:name, "cannot be empty") if name.nil? || name.strip.empty?
15
+ if priority && !VALID_PRIORITIES.include?(priority)
16
+ errors.add(:priority, "must be one of: #{VALID_PRIORITIES.join(', ')}")
17
+ end
18
+ if status && !VALID_STATUSES.include?(status)
19
+ errors.add(:status, "must be one of: #{VALID_STATUSES.join(', ')}")
20
+ end
21
+ end
22
+
23
+ def tag_list
24
+ (tags || "").split(",").map(&:strip).reject(&:empty?)
25
+ end
26
+
27
+ def overdue?
28
+ return false unless due_day
29
+ due_day < Date.today && !%w[done cancelled].include?(status)
30
+ end
31
+ end
32
+
33
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tasku
4
+ VERSION = "0.1.0"
5
+ end
data/lib/tasku.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "pastel"
5
+ require "tty-prompt"
6
+
7
+ require_relative "tasku/version"
8
+ require_relative "tasku/database"
9
+ Tasku::Database.connect
10
+ require_relative "tasku/task"
11
+ require_relative "tasku/output/terminal"
12
+ require_relative "tasku/cli"
metadata ADDED
@@ -0,0 +1,117 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tasku
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tom Dringer
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-06-14 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: thor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: pastel
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.8'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.8'
40
+ - !ruby/object:Gem::Dependency
41
+ name: tty-prompt
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.23'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.23'
54
+ - !ruby/object:Gem::Dependency
55
+ name: sequel
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '5.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '5.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: sqlite3
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.6'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.6'
82
+ description: Tasku is a terminal-based task manager with colour-coded priorities,
83
+ status tracking, SQLite persistence, and a beautiful CLI interface.
84
+ executables:
85
+ - tasku
86
+ extensions: []
87
+ extra_rdoc_files: []
88
+ files:
89
+ - exe/tasku
90
+ - lib/tasku.rb
91
+ - lib/tasku/cli.rb
92
+ - lib/tasku/database.rb
93
+ - lib/tasku/output/terminal.rb
94
+ - lib/tasku/task.rb
95
+ - lib/tasku/version.rb
96
+ homepage: https://github.com/tomdringer/tasku
97
+ licenses:
98
+ - MIT
99
+ metadata: {}
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: 3.1.0
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ requirements: []
114
+ rubygems_version: 3.6.2
115
+ specification_version: 4
116
+ summary: タスクリスト — a beautiful terminal task manager
117
+ test_files: []