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 +7 -0
- data/exe/tasku +6 -0
- data/lib/tasku/cli.rb +343 -0
- data/lib/tasku/database.rb +42 -0
- data/lib/tasku/output/terminal.rb +199 -0
- data/lib/tasku/task.rb +33 -0
- data/lib/tasku/version.rb +5 -0
- data/lib/tasku.rb +12 -0
- metadata +117 -0
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
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
|
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: []
|