tasku 0.3.9 → 0.4.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 +4 -4
- data/lib/tasku/cli.rb +345 -11
- data/lib/tasku/config.rb +103 -0
- data/lib/tasku/database.rb +12 -0
- data/lib/tasku/output/terminal.rb +162 -31
- data/lib/tasku/task.rb +6 -0
- data/lib/tasku/tui/mado_list.rb +118 -17
- data/lib/tasku/tui/mado_log.rb +18 -0
- data/lib/tasku/version.rb +1 -1
- data/lib/tasku.rb +6 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cd2fbd40e27817e56591b3a6ff769c59b109de5e34f2a09b416e38008de1428f
|
|
4
|
+
data.tar.gz: 787ea03c2a16d886bf62869d8e5174f28e5be667822e113fc248b32135e175bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3998485e4d8369986f96db7ab64330e1f9b19993c025288899920fb02f6c6670fd0e9980cfcd881a755c00118e5ea002a980b3ca543d094f15d750c8cb86fb46
|
|
7
|
+
data.tar.gz: 3c7a5a32c70554e2824ba41d7cc8197669c57ff63e339cc0e7c9a0b5b40606018ba181c6baea59bf4cf1de777740148660d3bd2dbcfe241cd4498aff78dd34f0
|
data/lib/tasku/cli.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
3
8
|
module Tasku
|
|
4
9
|
module CLI
|
|
5
10
|
LOGO = <<~LOGO
|
|
@@ -20,7 +25,11 @@ module Tasku
|
|
|
20
25
|
puts ""
|
|
21
26
|
Tasku::Config::VALID_KEYS.each do |key, meta|
|
|
22
27
|
current = Tasku::Config.get(key)
|
|
23
|
-
|
|
28
|
+
if meta[:range]
|
|
29
|
+
options_str = pastel.bold(pastel.green(current)) + pastel.dim(" (#{meta[:range].min}–#{meta[:range].max})")
|
|
30
|
+
else
|
|
31
|
+
options_str = meta[:values].map { |v| v == current ? pastel.bold(pastel.green(v)) : pastel.dim(v) }.join(", ")
|
|
32
|
+
end
|
|
24
33
|
puts " #{pastel.bold(key.ljust(20))} #{options_str} #{pastel.dim("— #{meta[:description]}")}"
|
|
25
34
|
end
|
|
26
35
|
puts ""
|
|
@@ -31,10 +40,11 @@ module Tasku
|
|
|
31
40
|
pastel = Pastel.new
|
|
32
41
|
meta = Tasku::Config::VALID_KEYS[key]
|
|
33
42
|
abort pastel.red("Unknown preference '#{key}'. Run `tasku config list` to see available keys.") unless meta
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
begin
|
|
44
|
+
Tasku::Config.set(key, value)
|
|
45
|
+
rescue ArgumentError => e
|
|
46
|
+
abort pastel.red(" #{e.message}")
|
|
36
47
|
end
|
|
37
|
-
Tasku::Config.set(key, value)
|
|
38
48
|
puts pastel.green(" ✓ #{key} set to '#{value}'.")
|
|
39
49
|
end
|
|
40
50
|
|
|
@@ -45,6 +55,224 @@ module Tasku
|
|
|
45
55
|
abort pastel.red("Unknown preference '#{key}'. Run `tasku config list` to see available keys.") unless meta
|
|
46
56
|
puts " #{key}: #{pastel.bold(Tasku::Config.get(key))}"
|
|
47
57
|
end
|
|
58
|
+
|
|
59
|
+
desc "open", "Open the config file in $EDITOR"
|
|
60
|
+
def open
|
|
61
|
+
path = Tasku::Config::CONFIG_PATH
|
|
62
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
63
|
+
# Seed file with all defaults so every key is visible for editing.
|
|
64
|
+
current = Tasku::Config.all
|
|
65
|
+
seeded = Tasku::Config::VALID_KEYS.transform_values { |meta| meta[:default] }.merge(current)
|
|
66
|
+
File.write(path, JSON.pretty_generate(seeded))
|
|
67
|
+
editor = ENV["EDITOR"] || ENV["VISUAL"] || "vi"
|
|
68
|
+
exec(editor, path)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class CloudApp < Thor
|
|
73
|
+
desc "login TOKEN", "Authenticate with Tasku Cloud using an API token"
|
|
74
|
+
option :url, type: :string, desc: "Tasku Cloud URL (default: #{Tasku::Config::CLOUD_URL_DEFAULT})"
|
|
75
|
+
def login(token)
|
|
76
|
+
pastel = Pastel.new
|
|
77
|
+
url = options[:url] || Tasku::Config.cloud_url
|
|
78
|
+
|
|
79
|
+
puts pastel.dim(" Verifying token with #{url}...")
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
uri = URI.join(url, "/api/v1/sync")
|
|
83
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
84
|
+
http.use_ssl = uri.scheme == "https"
|
|
85
|
+
http.open_timeout = 10
|
|
86
|
+
http.read_timeout = 10
|
|
87
|
+
|
|
88
|
+
req = Net::HTTP::Post.new(uri.path, {
|
|
89
|
+
"Content-Type" => "application/json",
|
|
90
|
+
"Authorization" => "Bearer #{token}"
|
|
91
|
+
})
|
|
92
|
+
req.body = JSON.generate({ tasks: [], last_synced_at: nil })
|
|
93
|
+
|
|
94
|
+
res = http.request(req)
|
|
95
|
+
rescue Errno::ECONNREFUSED, Errno::ENOENT, SocketError, Net::OpenTimeout => e
|
|
96
|
+
abort pastel.red(" Could not connect to #{url}: #{e.message}")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if res.code == "200"
|
|
100
|
+
Tasku::Config.cloud_token = token
|
|
101
|
+
Tasku::Config.cloud_url = url if options[:url]
|
|
102
|
+
puts ""
|
|
103
|
+
puts pastel.green(" ✓ Logged in to Tasku Cloud.")
|
|
104
|
+
puts pastel.dim(" Token saved to ~/.tasku/config.json")
|
|
105
|
+
puts pastel.dim(" Run `tasku cloud sync` to sync your tasks.")
|
|
106
|
+
puts ""
|
|
107
|
+
elsif res.code == "401"
|
|
108
|
+
abort pastel.red(" Invalid token. Check it and try again.")
|
|
109
|
+
else
|
|
110
|
+
abort pastel.red(" Unexpected response from server (#{res.code}).")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
desc "status", "Show Tasku Cloud connection status"
|
|
115
|
+
def status
|
|
116
|
+
pastel = Pastel.new
|
|
117
|
+
puts ""
|
|
118
|
+
if Tasku::Config.cloud_configured?
|
|
119
|
+
puts " #{pastel.green("●")} Connected to #{pastel.bold(Tasku::Config.cloud_url)}"
|
|
120
|
+
last = Tasku::Config.last_synced_at
|
|
121
|
+
puts " Last synced: #{last ? pastel.bold(last.localtime.strftime("%-d %b %Y at %H:%M")) : pastel.dim("never")}"
|
|
122
|
+
else
|
|
123
|
+
puts " #{pastel.dim("○")} Not connected."
|
|
124
|
+
puts " #{pastel.dim("Run `tasku cloud login <token>` to connect.")}"
|
|
125
|
+
end
|
|
126
|
+
puts ""
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
desc "logout", "Remove saved Tasku Cloud credentials"
|
|
130
|
+
def logout
|
|
131
|
+
pastel = Pastel.new
|
|
132
|
+
Tasku::Config.cloud_token = nil
|
|
133
|
+
Tasku::Config.last_synced_at = nil
|
|
134
|
+
puts pastel.green(" ✓ Logged out of Tasku Cloud.")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
desc "sync", "Two-way sync tasks with Tasku Cloud"
|
|
138
|
+
def sync
|
|
139
|
+
pastel = Pastel.new
|
|
140
|
+
|
|
141
|
+
unless Tasku::Config.cloud_configured?
|
|
142
|
+
abort pastel.red(" Not connected. Run `tasku cloud login <token>` first.")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
url = Tasku::Config.cloud_url
|
|
146
|
+
token = Tasku::Config.cloud_token
|
|
147
|
+
last_synced_at = Tasku::Config.last_synced_at
|
|
148
|
+
|
|
149
|
+
puts pastel.dim(" Syncing with #{url}...")
|
|
150
|
+
|
|
151
|
+
# Serialise every local task for upload
|
|
152
|
+
local_tasks = Tasku::Task.all.map do |t|
|
|
153
|
+
{
|
|
154
|
+
uuid: t.uuid,
|
|
155
|
+
name: t.name,
|
|
156
|
+
description: t.description,
|
|
157
|
+
project: t.project,
|
|
158
|
+
category: t.category,
|
|
159
|
+
start_day: t.start_day&.iso8601,
|
|
160
|
+
due_day: t.due_day&.iso8601,
|
|
161
|
+
code: t.code,
|
|
162
|
+
priority: t.priority,
|
|
163
|
+
status: t.status,
|
|
164
|
+
tags: t.tags,
|
|
165
|
+
estimated_hours: t.estimated_hours,
|
|
166
|
+
updated_at: t.updated_at&.utc&.iso8601,
|
|
167
|
+
created_at: t.created_at&.utc&.iso8601
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
begin
|
|
172
|
+
uri = URI.join(url, "/api/v1/sync")
|
|
173
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
174
|
+
http.use_ssl = uri.scheme == "https"
|
|
175
|
+
http.open_timeout = 15
|
|
176
|
+
http.read_timeout = 30
|
|
177
|
+
|
|
178
|
+
req = Net::HTTP::Post.new(uri.path, {
|
|
179
|
+
"Content-Type" => "application/json",
|
|
180
|
+
"Authorization" => "Bearer #{token}"
|
|
181
|
+
})
|
|
182
|
+
local_projects = Tasku::Database.db[:projects].all.map do |p|
|
|
183
|
+
{ name: p[:name], colour: p[:colour] }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
req.body = JSON.generate({ tasks: local_tasks, projects: local_projects, last_synced_at: last_synced_at&.iso8601 })
|
|
187
|
+
|
|
188
|
+
res = http.request(req)
|
|
189
|
+
rescue Errno::ECONNREFUSED, Errno::ENOENT, SocketError, Net::OpenTimeout => e
|
|
190
|
+
abort pastel.red(" Could not connect to #{url}: #{e.message}")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
if res.code == "401"
|
|
194
|
+
abort pastel.red(" Invalid token — run `tasku cloud login <token>` to reauthenticate.")
|
|
195
|
+
elsif res.code != "200"
|
|
196
|
+
abort pastel.red(" Sync failed (HTTP #{res.code}).")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
body = JSON.parse(res.body)
|
|
200
|
+
server_tasks = body["tasks"] || []
|
|
201
|
+
synced_at = body["synced_at"]
|
|
202
|
+
|
|
203
|
+
created = updated = conflicts = 0
|
|
204
|
+
|
|
205
|
+
server_tasks.each do |st|
|
|
206
|
+
next unless st["uuid"]
|
|
207
|
+
|
|
208
|
+
# Conflict tasks live server-side; the user resolves them on the web.
|
|
209
|
+
if st["conflict"]
|
|
210
|
+
conflicts += 1
|
|
211
|
+
next
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
local = Tasku::Task.first(uuid: st["uuid"])
|
|
215
|
+
|
|
216
|
+
server_updated = st["updated_at"] ? Time.parse(st["updated_at"]).utc : nil
|
|
217
|
+
|
|
218
|
+
if local.nil?
|
|
219
|
+
# New task from cloud — write directly to bypass timestamps plugin
|
|
220
|
+
# so updated_at matches the server's value exactly.
|
|
221
|
+
Tasku::Database.db[:tasks].insert(
|
|
222
|
+
uuid: st["uuid"],
|
|
223
|
+
name: st["name"],
|
|
224
|
+
description: st["description"],
|
|
225
|
+
project: st["project"],
|
|
226
|
+
category: st["category"],
|
|
227
|
+
start_day: st["start_day"] ? Date.parse(st["start_day"]) : nil,
|
|
228
|
+
due_day: st["due_day"] ? Date.parse(st["due_day"]) : nil,
|
|
229
|
+
code: st["code"],
|
|
230
|
+
priority: st["priority"] || "none",
|
|
231
|
+
status: st["status"] || "todo",
|
|
232
|
+
tags: st["tags"],
|
|
233
|
+
estimated_hours: st["estimated_hours"],
|
|
234
|
+
created_at: st["created_at"] ? Time.parse(st["created_at"]).utc : Time.now.utc,
|
|
235
|
+
updated_at: server_updated || Time.now.utc
|
|
236
|
+
)
|
|
237
|
+
created += 1
|
|
238
|
+
else
|
|
239
|
+
local_updated = local.updated_at ? local.updated_at.utc : nil
|
|
240
|
+
|
|
241
|
+
if server_updated && local_updated && server_updated > local_updated
|
|
242
|
+
# Write via dataset to preserve server's updated_at exactly,
|
|
243
|
+
# preventing the local timestamp from drifting forward and
|
|
244
|
+
# triggering another upload next sync.
|
|
245
|
+
Tasku::Database.db[:tasks].where(id: local.id).update(
|
|
246
|
+
name: st["name"],
|
|
247
|
+
description: st["description"],
|
|
248
|
+
project: st["project"],
|
|
249
|
+
category: st["category"],
|
|
250
|
+
start_day: st["start_day"] ? Date.parse(st["start_day"]) : nil,
|
|
251
|
+
due_day: st["due_day"] ? Date.parse(st["due_day"]) : nil,
|
|
252
|
+
code: st["code"],
|
|
253
|
+
priority: st["priority"] || "none",
|
|
254
|
+
status: st["status"] || "todo",
|
|
255
|
+
tags: st["tags"],
|
|
256
|
+
estimated_hours: st["estimated_hours"],
|
|
257
|
+
updated_at: server_updated
|
|
258
|
+
)
|
|
259
|
+
updated += 1
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
Tasku::Config.last_synced_at = synced_at ? Time.parse(synced_at).utc : Time.now.utc
|
|
265
|
+
|
|
266
|
+
puts ""
|
|
267
|
+
puts " #{pastel.green("✓")} Sync complete."
|
|
268
|
+
puts " #{pastel.bold(local_tasks.count.to_s)} #{local_tasks.count == 1 ? "task" : "tasks"} pushed to cloud."
|
|
269
|
+
puts " #{pastel.bold(created.to_s)} #{created == 1 ? "task" : "tasks"} pulled from cloud." if created > 0
|
|
270
|
+
puts " #{pastel.bold(updated.to_s)} #{updated == 1 ? "task" : "tasks"} updated from cloud." if updated > 0
|
|
271
|
+
if conflicts > 0
|
|
272
|
+
puts " #{pastel.yellow("⚠")} #{conflicts} #{conflicts == 1 ? "conflict" : "conflicts"} — visit #{pastel.bold(url)} to resolve."
|
|
273
|
+
end
|
|
274
|
+
puts ""
|
|
275
|
+
end
|
|
48
276
|
end
|
|
49
277
|
|
|
50
278
|
class App < Thor
|
|
@@ -74,6 +302,9 @@ module Tasku
|
|
|
74
302
|
desc "config SUBCOMMAND", "Manage user preferences"
|
|
75
303
|
subcommand "config", ConfigApp
|
|
76
304
|
|
|
305
|
+
desc "cloud SUBCOMMAND", "Sync tasks with Tasku Cloud"
|
|
306
|
+
subcommand "cloud", CloudApp
|
|
307
|
+
|
|
77
308
|
def help(*args)
|
|
78
309
|
if args.empty?
|
|
79
310
|
puts ""
|
|
@@ -161,6 +392,11 @@ module Tasku
|
|
|
161
392
|
option_add
|
|
162
393
|
end
|
|
163
394
|
|
|
395
|
+
if attrs.nil?
|
|
396
|
+
puts pastel.yellow(" Add cancelled.")
|
|
397
|
+
return
|
|
398
|
+
end
|
|
399
|
+
|
|
164
400
|
task = Task.create(attrs)
|
|
165
401
|
terminal.render_added(task)
|
|
166
402
|
rescue Sequel::ValidationFailed => e
|
|
@@ -173,9 +409,11 @@ module Tasku
|
|
|
173
409
|
option :project, type: :string, desc: "Filter by project"
|
|
174
410
|
option :category, type: :string, desc: "Filter by category"
|
|
175
411
|
option :tags, type: :string, desc: "Filter by tag (comma-separated)"
|
|
176
|
-
option :overdue,
|
|
177
|
-
option :
|
|
178
|
-
option :
|
|
412
|
+
option :overdue, type: :boolean, desc: "Show only overdue tasks"
|
|
413
|
+
option :today, type: :boolean, desc: "Show tasks due today"
|
|
414
|
+
option :tomorrow, type: :boolean, desc: "Show tasks due tomorrow"
|
|
415
|
+
option :sort, type: :string, desc: "Sort by: id, name, priority, due, status, created"
|
|
416
|
+
option :order, type: :string, desc: "Order: asc, desc", default: "asc"
|
|
179
417
|
def list
|
|
180
418
|
dataset = Task.dataset
|
|
181
419
|
|
|
@@ -196,6 +434,16 @@ module Tasku
|
|
|
196
434
|
dataset = dataset.where { due_day < today }.exclude(status: %w[done cancelled])
|
|
197
435
|
end
|
|
198
436
|
|
|
437
|
+
if options[:today]
|
|
438
|
+
today = Date.today
|
|
439
|
+
dataset = dataset.where(due_day: today).exclude(status: %w[done cancelled])
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
if options[:tomorrow]
|
|
443
|
+
tomorrow = Date.today + 1
|
|
444
|
+
dataset = dataset.where(due_day: tomorrow).exclude(status: %w[done cancelled])
|
|
445
|
+
end
|
|
446
|
+
|
|
199
447
|
sort_col = case options[:sort]
|
|
200
448
|
when "name" then :name
|
|
201
449
|
when "priority" then Sequel.case(Task::VALID_PRIORITIES.each_with_index.to_h, 999, :priority)
|
|
@@ -210,10 +458,21 @@ module Tasku
|
|
|
210
458
|
|
|
211
459
|
tasks = dataset.all
|
|
212
460
|
bar = { "bar_project" => Config.get("bar_project"), "bar_priority" => Config.get("bar_priority"), "bar_status" => Config.get("bar_status") }
|
|
461
|
+
cols_cfg = {
|
|
462
|
+
"col_project" => Config.get("col_project"),
|
|
463
|
+
"col_category" => Config.get("col_category"),
|
|
464
|
+
"col_priority" => Config.get("col_priority"),
|
|
465
|
+
"col_status" => Config.get("col_status"),
|
|
466
|
+
"col_due" => Config.get("col_due"),
|
|
467
|
+
"col_name_min" => Config.get("col_name_min"),
|
|
468
|
+
"col_priority_min" => Config.get("col_priority_min"),
|
|
469
|
+
"col_status_min" => Config.get("col_status_min"),
|
|
470
|
+
"col_due_min" => Config.get("col_due_min")
|
|
471
|
+
}
|
|
213
472
|
if ENV["MADO"] == "1"
|
|
214
|
-
Tasku::TUI::MadoList.new(tasks, colour_map: Project.colour_map, bar: bar).run
|
|
473
|
+
Tasku::TUI::MadoList.new(tasks, colour_map: Project.colour_map, bar: bar, cols_cfg: cols_cfg, project: options[:project]).run
|
|
215
474
|
else
|
|
216
|
-
terminal.render_list(tasks, colour_map: Project.colour_map, spacing: Config.get("list_spacing"), bar: bar)
|
|
475
|
+
terminal.render_list(tasks, colour_map: Project.colour_map, spacing: Config.get("list_spacing"), bar: bar, cols_cfg: cols_cfg)
|
|
217
476
|
end
|
|
218
477
|
end
|
|
219
478
|
|
|
@@ -237,10 +496,19 @@ module Tasku
|
|
|
237
496
|
option :hours, type: :numeric, desc: "Estimated hours"
|
|
238
497
|
option :code, type: :string, desc: "Code"
|
|
239
498
|
option :clear, type: :string, desc: "Clear a field: description, start, due, model, tags, hours, code"
|
|
499
|
+
option :interactive, type: :boolean, aliases: "-i", desc: "Interactive mode", default: false
|
|
240
500
|
def edit(id)
|
|
241
501
|
task = find_task(id)
|
|
242
502
|
|
|
243
|
-
attrs =
|
|
503
|
+
attrs = if options[:interactive]
|
|
504
|
+
interactive_edit(task)
|
|
505
|
+
else
|
|
506
|
+
option_edit
|
|
507
|
+
end
|
|
508
|
+
if attrs.nil?
|
|
509
|
+
puts pastel.yellow(" Edit cancelled.")
|
|
510
|
+
return
|
|
511
|
+
end
|
|
244
512
|
if attrs.empty? && !options[:clear]
|
|
245
513
|
puts pastel.yellow("No changes specified. Use --help to see available options.")
|
|
246
514
|
return
|
|
@@ -296,8 +564,11 @@ module Tasku
|
|
|
296
564
|
end
|
|
297
565
|
|
|
298
566
|
desc "stats", "Show task statistics"
|
|
567
|
+
option :project, type: :string, desc: "Filter by project"
|
|
299
568
|
def stats
|
|
300
|
-
|
|
569
|
+
dataset = Task.dataset
|
|
570
|
+
dataset = dataset.where(project: options[:project]) if options[:project]
|
|
571
|
+
tasks = dataset.all
|
|
301
572
|
terminal.render_stats(tasks, colour_map: Project.colour_map) if tasks
|
|
302
573
|
end
|
|
303
574
|
|
|
@@ -437,6 +708,69 @@ module Tasku
|
|
|
437
708
|
tags: (tags unless tags&.empty?),
|
|
438
709
|
estimated_hours: hours
|
|
439
710
|
}.compact
|
|
711
|
+
rescue TTY::Reader::InputInterrupt
|
|
712
|
+
nil
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def interactive_edit(task)
|
|
716
|
+
prompt = TTY::Prompt.new
|
|
717
|
+
|
|
718
|
+
name = prompt.ask("Task name:", default: task.name, required: true) do |q|
|
|
719
|
+
q.modify :strip
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
description = prompt.ask("Description:", default: task.description || "")
|
|
723
|
+
description = nil if description&.empty?
|
|
724
|
+
|
|
725
|
+
project = prompt.ask("Project:", default: task.project || "")
|
|
726
|
+
project = nil if project&.empty?
|
|
727
|
+
|
|
728
|
+
category = prompt.ask("Category:", default: task.category || "")
|
|
729
|
+
category = nil if category&.empty?
|
|
730
|
+
|
|
731
|
+
priority = prompt.select("Priority?", %w[none low medium high urgent],
|
|
732
|
+
default: task.priority || "none")
|
|
733
|
+
status = prompt.select("Status?", Task::VALID_STATUSES,
|
|
734
|
+
default: task.status || "todo")
|
|
735
|
+
|
|
736
|
+
start_day = prompt.ask("Start date (YYYY-MM-DD, optional):",
|
|
737
|
+
default: task.start_day&.to_s || "")
|
|
738
|
+
start_day = nil if start_day&.empty?
|
|
739
|
+
|
|
740
|
+
due_day = prompt.ask("Due date (YYYY-MM-DD, optional):",
|
|
741
|
+
default: task.due_day&.to_s || "")
|
|
742
|
+
due_day = nil if due_day&.empty?
|
|
743
|
+
|
|
744
|
+
model_name = prompt.ask("Model:", default: task.model_name || "")
|
|
745
|
+
model_name = nil if model_name&.empty?
|
|
746
|
+
|
|
747
|
+
code = prompt.ask("Code:", default: task.code || "")
|
|
748
|
+
code = nil if code&.empty?
|
|
749
|
+
|
|
750
|
+
tags = prompt.ask("Tags (comma-separated):", default: task.tags || "")
|
|
751
|
+
tags = nil if tags&.empty?
|
|
752
|
+
|
|
753
|
+
hours = prompt.ask("Estimated hours:", default: task.estimated_hours&.to_s || "") do |q|
|
|
754
|
+
q.convert(:float, "")
|
|
755
|
+
end
|
|
756
|
+
hours = nil if hours.is_a?(String) && hours.empty?
|
|
757
|
+
|
|
758
|
+
{
|
|
759
|
+
name: name,
|
|
760
|
+
description: description,
|
|
761
|
+
project: project,
|
|
762
|
+
category: category,
|
|
763
|
+
start_day: parse_date(start_day),
|
|
764
|
+
due_day: parse_date(due_day),
|
|
765
|
+
model_name: model_name,
|
|
766
|
+
code: code,
|
|
767
|
+
priority: priority,
|
|
768
|
+
status: status,
|
|
769
|
+
tags: tags,
|
|
770
|
+
estimated_hours: hours
|
|
771
|
+
}.compact
|
|
772
|
+
rescue TTY::Reader::InputInterrupt
|
|
773
|
+
nil
|
|
440
774
|
end
|
|
441
775
|
|
|
442
776
|
def option_add
|
data/lib/tasku/config.rb
CHANGED
|
@@ -27,14 +27,104 @@ module Tasku
|
|
|
27
27
|
values: %w[on off],
|
|
28
28
|
default: "on",
|
|
29
29
|
description: "Show status colour segment in row bar"
|
|
30
|
+
},
|
|
31
|
+
"col_project" => {
|
|
32
|
+
values: %w[on off],
|
|
33
|
+
default: "off",
|
|
34
|
+
description: "Show project column in task list"
|
|
35
|
+
},
|
|
36
|
+
"col_priority" => {
|
|
37
|
+
values: %w[on off],
|
|
38
|
+
default: "on",
|
|
39
|
+
description: "Show priority column in task list"
|
|
40
|
+
},
|
|
41
|
+
"col_status" => {
|
|
42
|
+
values: %w[on off],
|
|
43
|
+
default: "on",
|
|
44
|
+
description: "Show status column in task list"
|
|
45
|
+
},
|
|
46
|
+
"col_due" => {
|
|
47
|
+
values: %w[on off],
|
|
48
|
+
default: "on",
|
|
49
|
+
description: "Show due date column in task list"
|
|
50
|
+
},
|
|
51
|
+
"col_name_min" => {
|
|
52
|
+
range: 10..200,
|
|
53
|
+
default: "20",
|
|
54
|
+
description: "Minimum width (chars) of the name column"
|
|
55
|
+
},
|
|
56
|
+
"col_priority_min" => {
|
|
57
|
+
range: 4..40,
|
|
58
|
+
default: "10",
|
|
59
|
+
description: "Minimum width (chars) of the priority column"
|
|
60
|
+
},
|
|
61
|
+
"col_status_min" => {
|
|
62
|
+
range: 4..40,
|
|
63
|
+
default: "11",
|
|
64
|
+
description: "Minimum width (chars) of the status column"
|
|
65
|
+
},
|
|
66
|
+
"col_due_min" => {
|
|
67
|
+
range: 4..40,
|
|
68
|
+
default: "19",
|
|
69
|
+
description: "Minimum width (chars) of the due date column"
|
|
30
70
|
}
|
|
31
71
|
}.freeze
|
|
32
72
|
|
|
73
|
+
# ── Cloud sync settings ────────────────────────────────────────────────
|
|
74
|
+
CLOUD_URL_DEFAULT = "https://tasku.cloud".freeze
|
|
75
|
+
|
|
76
|
+
def self.cloud_token
|
|
77
|
+
cloud["token"]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.cloud_token=(value)
|
|
81
|
+
save_cloud("token", value)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.cloud_url
|
|
85
|
+
cloud["url"] || CLOUD_URL_DEFAULT
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.cloud_url=(value)
|
|
89
|
+
save_cloud("url", value)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.last_synced_at
|
|
93
|
+
raw = cloud["last_synced_at"]
|
|
94
|
+
raw ? Time.parse(raw).utc : nil
|
|
95
|
+
rescue ArgumentError, TypeError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.last_synced_at=(time)
|
|
100
|
+
save_cloud("last_synced_at", time&.utc&.iso8601)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.cloud_configured?
|
|
104
|
+
!cloud_token.nil?
|
|
105
|
+
end
|
|
106
|
+
|
|
33
107
|
def self.get(key)
|
|
34
108
|
all[key] || VALID_KEYS.dig(key, :default)
|
|
35
109
|
end
|
|
36
110
|
|
|
111
|
+
def self.get_int(key)
|
|
112
|
+
get(key).to_i
|
|
113
|
+
end
|
|
114
|
+
|
|
37
115
|
def self.set(key, value)
|
|
116
|
+
pastel = Pastel.new rescue nil
|
|
117
|
+
meta = VALID_KEYS[key]
|
|
118
|
+
raise ArgumentError, "Unknown key '#{key}'" unless meta
|
|
119
|
+
|
|
120
|
+
if meta[:range]
|
|
121
|
+
int = Integer(value) rescue nil
|
|
122
|
+
raise ArgumentError, "Value must be an integer between #{meta[:range].min} and #{meta[:range].max}" unless int && meta[:range].include?(int)
|
|
123
|
+
value = int.to_s
|
|
124
|
+
elsif meta[:values]
|
|
125
|
+
raise ArgumentError, "Invalid value '#{value}'. Valid: #{meta[:values].join(', ')}" unless meta[:values].include?(value)
|
|
126
|
+
end
|
|
127
|
+
|
|
38
128
|
current = all
|
|
39
129
|
current[key] = value
|
|
40
130
|
FileUtils.mkdir_p(File.dirname(CONFIG_PATH))
|
|
@@ -48,5 +138,18 @@ module Tasku
|
|
|
48
138
|
rescue JSON::ParserError
|
|
49
139
|
{}
|
|
50
140
|
end
|
|
141
|
+
|
|
142
|
+
def self.cloud
|
|
143
|
+
all["cloud"] || {}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.save_cloud(key, value)
|
|
147
|
+
current = all
|
|
148
|
+
current["cloud"] ||= {}
|
|
149
|
+
current["cloud"][key] = value
|
|
150
|
+
FileUtils.mkdir_p(File.dirname(CONFIG_PATH))
|
|
151
|
+
File.write(CONFIG_PATH, JSON.pretty_generate(current))
|
|
152
|
+
end
|
|
153
|
+
private_class_method :cloud, :save_cloud
|
|
51
154
|
end
|
|
52
155
|
end
|
data/lib/tasku/database.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "sequel"
|
|
4
4
|
require "fileutils"
|
|
5
|
+
require "securerandom"
|
|
5
6
|
|
|
6
7
|
module Tasku
|
|
7
8
|
module Database
|
|
@@ -10,6 +11,9 @@ module Tasku
|
|
|
10
11
|
|
|
11
12
|
def self.connect
|
|
12
13
|
FileUtils.mkdir_p(DB_DIR)
|
|
14
|
+
# Treat all stored datetime strings as UTC so comparisons with
|
|
15
|
+
# cloud timestamps (which are always UTC) stay consistent.
|
|
16
|
+
Sequel.database_timezone = :utc
|
|
13
17
|
@db = Sequel.sqlite(DB_PATH)
|
|
14
18
|
Sequel::Model.db = @db
|
|
15
19
|
migrate
|
|
@@ -43,6 +47,14 @@ module Tasku
|
|
|
43
47
|
db.alter_table(:tasks) { add_column :code, String }
|
|
44
48
|
end
|
|
45
49
|
|
|
50
|
+
if db.table_exists?(:tasks) && !db.schema(:tasks).map(&:first).include?(:uuid)
|
|
51
|
+
db.alter_table(:tasks) { add_column :uuid, String }
|
|
52
|
+
# Backfill existing tasks with UUIDs
|
|
53
|
+
db[:tasks].where(uuid: nil).each do |row|
|
|
54
|
+
db[:tasks].where(id: row[:id]).update(uuid: SecureRandom.uuid)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
46
58
|
db.create_table? :projects do
|
|
47
59
|
String :name, primary_key: true
|
|
48
60
|
String :colour
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "pastel"
|
|
4
|
+
require "io/console"
|
|
4
5
|
|
|
5
6
|
module Tasku
|
|
6
7
|
module Output
|
|
@@ -39,27 +40,30 @@ module Tasku
|
|
|
39
40
|
"archived" => { color: :dim, symbol: "⊘" }
|
|
40
41
|
}.freeze
|
|
41
42
|
|
|
42
|
-
COL_SEP = "
|
|
43
|
+
COL_SEP = " │ "
|
|
44
|
+
# Maximum visible width of a due_cell value:
|
|
45
|
+
# "Mmm DD (NNNd ago)" = 17 chars.
|
|
46
|
+
DUE_COL_MIN_WIDTH = 17
|
|
43
47
|
|
|
44
48
|
def initialize
|
|
45
49
|
@pastel = Pastel.new
|
|
46
50
|
@term_width = terminal_width
|
|
47
51
|
end
|
|
48
52
|
|
|
49
|
-
def render_list(tasks, colour_map: {}, spacing: "compact", bar: {})
|
|
53
|
+
def render_list(tasks, colour_map: {}, spacing: "compact", bar: {}, cols_cfg: {})
|
|
50
54
|
if tasks.empty?
|
|
51
55
|
puts @pastel.yellow(" No tasks found.")
|
|
52
56
|
return
|
|
53
57
|
end
|
|
54
58
|
|
|
55
|
-
rows = tasks.map { |t| build_columns(t, colour_map, bar) }
|
|
56
|
-
col_widths = compute_widths(rows)
|
|
59
|
+
rows = tasks.map { |t| build_columns(t, colour_map, bar, cols_cfg) }
|
|
60
|
+
col_widths = compute_widths(rows, cols_cfg)
|
|
57
61
|
total = col_widths.sum + (COL_SEP.length * (col_widths.length - 1))
|
|
58
62
|
|
|
59
63
|
puts ""
|
|
60
64
|
puts @pastel.dim(" #{"─" * total}")
|
|
61
65
|
rows.each do |cols|
|
|
62
|
-
line = cols.each_with_index.map { |c, i| c.to_s
|
|
66
|
+
line = cols.each_with_index.map { |c, i| ansi_ljust(c.to_s, col_widths[i]) }.join(COL_SEP)
|
|
63
67
|
puts " #{line}"
|
|
64
68
|
puts "" if spacing == "spacious"
|
|
65
69
|
end
|
|
@@ -154,26 +158,55 @@ module Tasku
|
|
|
154
158
|
end
|
|
155
159
|
|
|
156
160
|
# Returns an array of pre-formatted row strings for MadoList.
|
|
157
|
-
# Accounts for the "[>] " prefix (
|
|
158
|
-
|
|
159
|
-
|
|
161
|
+
# Accounts for the " [>] " prefix (6 extra chars) when computing widths.
|
|
162
|
+
# Optional columns (project → priority → status, never due) are dropped
|
|
163
|
+
# until ≥15 chars are available for task names. Names are only truncated
|
|
164
|
+
# if they still overflow after all optional columns have been tried.
|
|
165
|
+
def build_mado_rows(tasks, colour_map = {}, bar = {}, cols_cfg = {})
|
|
166
|
+
return { rows: [], row_width: 0 } if tasks.empty?
|
|
160
167
|
|
|
161
168
|
orig_width = @term_width
|
|
162
|
-
@term_width = [
|
|
169
|
+
@term_width = [orig_width - 6, 40].max
|
|
163
170
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
171
|
+
cfg = cols_cfg.dup
|
|
172
|
+
|
|
173
|
+
# Drop optional columns (project → category → priority → status) until names have room.
|
|
174
|
+
%i[project category priority status].each do |drop_col|
|
|
175
|
+
probe = tasks.map { |t| build_columns(t, colour_map, bar, cfg) }
|
|
176
|
+
ws = mado_max_col_widths(probe, cfg)
|
|
177
|
+
break if mado_name_available(ws) >= 15
|
|
178
|
+
next if cfg["col_#{drop_col}"] == "off"
|
|
179
|
+
cfg["col_#{drop_col}"] = "off"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
col_rows = tasks.map { |t| build_columns(t, colour_map, bar, cfg) }
|
|
183
|
+
col_widths = mado_max_col_widths(col_rows, cfg)
|
|
184
|
+
col_widths = mado_apply_truncation(col_rows, col_widths)
|
|
185
|
+
|
|
186
|
+
dim_sep = @pastel.dim(COL_SEP)
|
|
187
|
+
result = col_rows.map do |cols|
|
|
188
|
+
cols.each_with_index.map { |c, i| ansi_ljust(c.to_s, col_widths[i]) }.join(dim_sep)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Build header row aligned to col_widths.
|
|
192
|
+
labels = ["ID", "Name"]
|
|
193
|
+
%i[project category priority status due].each do |col|
|
|
194
|
+
next if cfg["col_#{col}"] == "off"
|
|
195
|
+
labels << { project: "Project", category: "Category",
|
|
196
|
+
priority: "Priority", status: "Status", due: "Due" }[col]
|
|
168
197
|
end
|
|
198
|
+
header = labels.each_with_index
|
|
199
|
+
.map { |lbl, i| ansi_ljust(@pastel.dim(lbl), col_widths[i]) }
|
|
200
|
+
.join(dim_sep)
|
|
169
201
|
|
|
202
|
+
row_width = col_widths.sum + COL_SEP.length * (col_widths.length - 1)
|
|
170
203
|
@term_width = orig_width
|
|
171
|
-
result
|
|
204
|
+
{ rows: result, row_width: row_width, header: header }
|
|
172
205
|
end
|
|
173
206
|
|
|
174
207
|
private
|
|
175
208
|
|
|
176
|
-
def build_columns(task, colour_map = {}, bar = {})
|
|
209
|
+
def build_columns(task, colour_map = {}, bar = {}, cols_cfg = {})
|
|
177
210
|
id_val = task.code && !task.code.empty? ? "#{task.code}-#{task.id}" : task.id.to_s
|
|
178
211
|
|
|
179
212
|
segments = []
|
|
@@ -193,36 +226,120 @@ module Tasku
|
|
|
193
226
|
else
|
|
194
227
|
@pastel.dim(id_val)
|
|
195
228
|
end
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
229
|
+
|
|
230
|
+
# ID (col 0) and Name (col 1) are always present.
|
|
231
|
+
result = [id_str, @pastel.bold(task.name)]
|
|
232
|
+
result << project_str(task.project, colour_map) unless cols_cfg["col_project"] == "off"
|
|
233
|
+
result << category_str(task.category) unless cols_cfg["col_category"] == "off"
|
|
234
|
+
result << priority_tag(task.priority) unless cols_cfg["col_priority"] == "off"
|
|
235
|
+
result << status_tag(task.status) unless cols_cfg["col_status"] == "off"
|
|
236
|
+
result << due_cell(task) unless cols_cfg["col_due"] == "off"
|
|
237
|
+
result
|
|
202
238
|
end
|
|
203
239
|
|
|
204
|
-
def compute_widths(rows)
|
|
240
|
+
def compute_widths(rows, cols_cfg = {})
|
|
205
241
|
raw = rows.map { |cols| cols.map { |c| strip_ansi(c.to_s).length } }
|
|
206
242
|
maxes = raw.transpose.map(&:max)
|
|
207
|
-
sep_total = COL_SEP.length * (maxes.length - 1)
|
|
208
|
-
fixed_cols = maxes[0] + maxes[2] + maxes[3] + maxes[4] + maxes[5]
|
|
209
|
-
available_name = @term_width - fixed_cols - sep_total - 4
|
|
210
|
-
min_name = 20
|
|
211
243
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
244
|
+
# Track which array index each optional column landed at (after id=0, name=1).
|
|
245
|
+
col_idx = {}
|
|
246
|
+
i = 2
|
|
247
|
+
%i[project category priority status due].each do |col|
|
|
248
|
+
unless cols_cfg["col_#{col}"] == "off"
|
|
249
|
+
col_idx[col] = i
|
|
250
|
+
i += 1
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Apply user-configured minimum widths for optional fixed columns.
|
|
255
|
+
{
|
|
256
|
+
priority: [cols_cfg["col_priority_min"].to_i, 4].max,
|
|
257
|
+
status: [cols_cfg["col_status_min"].to_i, 4].max,
|
|
258
|
+
due: [[cols_cfg["col_due_min"].to_i, DUE_COL_MIN_WIDTH].max]
|
|
259
|
+
}.each do |col, mins|
|
|
260
|
+
if (idx = col_idx[col])
|
|
261
|
+
maxes[idx] = ([maxes[idx]] + Array(mins)).max
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
sep_total = COL_SEP.length * (maxes.length - 1)
|
|
266
|
+
# Fixed cols = everything except the name column (always at index 1).
|
|
267
|
+
fixed_cols = maxes.each_with_index.sum { |m, i| i == 1 ? 0 : m }
|
|
268
|
+
available_name = @term_width - fixed_cols - sep_total - 6
|
|
269
|
+
|
|
270
|
+
# Truncate names that would overflow; use count-based slice to avoid
|
|
271
|
+
# Ruby's negative-index wrapping when name_width is 0 or 1.
|
|
272
|
+
name_width = [available_name, 0].max
|
|
273
|
+
if maxes[1] > name_width
|
|
274
|
+
rows.each do |cols|
|
|
215
275
|
raw_str = strip_ansi(cols[1].to_s)
|
|
216
276
|
if raw_str.length > name_width
|
|
217
|
-
|
|
277
|
+
keep = [name_width - 1, 0].max
|
|
278
|
+
cols[1] = keep > 0 ? "#{raw_str[0, keep]}#{@pastel.dim("…")}" : ""
|
|
218
279
|
end
|
|
219
280
|
end
|
|
220
|
-
|
|
281
|
+
else
|
|
282
|
+
name_width = maxes[1]
|
|
283
|
+
end
|
|
284
|
+
maxes[1] = name_width
|
|
285
|
+
|
|
286
|
+
maxes
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Returns per-column max widths, applying configured minimums for
|
|
290
|
+
# priority (≥4), status (≥4), and due (≥DUE_COL_MIN_WIDTH).
|
|
291
|
+
# @term_width must already be reduced by 6 (the MadoList prefix).
|
|
292
|
+
def mado_max_col_widths(rows, cols_cfg = {})
|
|
293
|
+
raw = rows.map { |cols| cols.map { |c| strip_ansi(c.to_s).length } }
|
|
294
|
+
maxes = raw.transpose.map(&:max)
|
|
295
|
+
|
|
296
|
+
col_idx = {}
|
|
297
|
+
i = 2
|
|
298
|
+
%i[project category priority status due].each do |col|
|
|
299
|
+
next if cols_cfg["col_#{col}"] == "off"
|
|
300
|
+
col_idx[col] = i
|
|
301
|
+
i += 1
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
{ priority: [cols_cfg["col_priority_min"].to_i, 4].max,
|
|
305
|
+
status: [cols_cfg["col_status_min"].to_i, 4].max,
|
|
306
|
+
due: [cols_cfg["col_due_min"].to_i, DUE_COL_MIN_WIDTH].max
|
|
307
|
+
}.each do |col, min|
|
|
308
|
+
maxes[col_idx[col]] = [maxes[col_idx[col]], min].max if col_idx[col]
|
|
221
309
|
end
|
|
222
310
|
|
|
223
311
|
maxes
|
|
224
312
|
end
|
|
225
313
|
|
|
314
|
+
# How many chars are available for the name column given a widths array.
|
|
315
|
+
def mado_name_available(maxes)
|
|
316
|
+
sep_total = COL_SEP.length * (maxes.length - 1)
|
|
317
|
+
fixed_cols = maxes.each_with_index.sum { |m, i| i == 1 ? 0 : m }
|
|
318
|
+
@term_width - fixed_cols - sep_total
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Truncate name cells in-place if they exceed available space.
|
|
322
|
+
# Always expands the name column to fill available space so the table
|
|
323
|
+
# spans the full terminal width (short names are padded by ansi_ljust).
|
|
324
|
+
# Returns the (possibly updated) widths array.
|
|
325
|
+
def mado_apply_truncation(rows, maxes)
|
|
326
|
+
avail = mado_name_available(maxes)
|
|
327
|
+
name_w = [avail, 0].max
|
|
328
|
+
if maxes[1] > name_w
|
|
329
|
+
rows.each do |cols|
|
|
330
|
+
s = strip_ansi(cols[1].to_s)
|
|
331
|
+
if s.length > name_w
|
|
332
|
+
keep = [name_w - 1, 0].max
|
|
333
|
+
cols[1] = keep > 0 ? "#{s[0, keep]}#{@pastel.dim("…")}" : ""
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
# Always set name column to full available width so the table fills
|
|
338
|
+
# the terminal — short names will be space-padded by ansi_ljust.
|
|
339
|
+
maxes[1] = name_w
|
|
340
|
+
maxes
|
|
341
|
+
end
|
|
342
|
+
|
|
226
343
|
def strip_ansi(str)
|
|
227
344
|
str.gsub(/\e\[[0-9;]*m/, "")
|
|
228
345
|
end
|
|
@@ -236,6 +353,13 @@ module Tasku
|
|
|
236
353
|
end
|
|
237
354
|
|
|
238
355
|
def terminal_width
|
|
356
|
+
# Mado writes the exact PTY column count to this file before running
|
|
357
|
+
# tasku list, bypassing unreliable ioctl/winsize methods inside the PTY.
|
|
358
|
+
cols_file = ENV["TASKU_COLS_FILE"] || "/tmp/tasku_mado_cols"
|
|
359
|
+
cols = File.read(cols_file).strip.to_i rescue 0
|
|
360
|
+
return cols if cols > 0
|
|
361
|
+
|
|
362
|
+
`stty size 2>/dev/null`.split.last.to_i.tap { |w| return w if w > 0 }
|
|
239
363
|
IO.console&.winsize&.[](1) || 80
|
|
240
364
|
rescue
|
|
241
365
|
80
|
|
@@ -257,6 +381,10 @@ module Tasku
|
|
|
257
381
|
"\e[38;2;#{r};#{g};#{b}m#{text}\e[0m"
|
|
258
382
|
end
|
|
259
383
|
|
|
384
|
+
def category_str(category)
|
|
385
|
+
category && !category.empty? ? @pastel.cyan(category) : @pastel.dim("—")
|
|
386
|
+
end
|
|
387
|
+
|
|
260
388
|
def project_str(project, colour_map)
|
|
261
389
|
return @pastel.dim("—") unless project
|
|
262
390
|
|
|
@@ -274,6 +402,9 @@ module Tasku
|
|
|
274
402
|
day = task.due_day.strftime("%b %d")
|
|
275
403
|
if task.overdue?
|
|
276
404
|
@pastel.red("#{day} (#{diff.abs}d ago)")
|
|
405
|
+
elsif diff < 0
|
|
406
|
+
# Past due but task is done/cancelled — show dimly, no alarm
|
|
407
|
+
@pastel.dim("#{day} (#{diff.abs}d ago)")
|
|
277
408
|
elsif diff.zero?
|
|
278
409
|
@pastel.yellow("#{day} (today)")
|
|
279
410
|
elsif diff <= 7
|
data/lib/tasku/task.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "database"
|
|
4
|
+
require "securerandom"
|
|
4
5
|
|
|
5
6
|
module Tasku
|
|
6
7
|
class Task < Sequel::Model(:tasks)
|
|
@@ -9,6 +10,11 @@ module Tasku
|
|
|
9
10
|
VALID_PRIORITIES = %w[none low medium high urgent].freeze
|
|
10
11
|
VALID_STATUSES = %w[backlog todo in_progress done cancelled archived].freeze
|
|
11
12
|
|
|
13
|
+
def before_create
|
|
14
|
+
super
|
|
15
|
+
self.uuid ||= SecureRandom.uuid
|
|
16
|
+
end
|
|
17
|
+
|
|
12
18
|
def before_validation
|
|
13
19
|
super
|
|
14
20
|
self.code = code.to_s.upcase[0, 3] if code
|
data/lib/tasku/tui/mado_list.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "io/console"
|
|
4
|
+
require "shellwords"
|
|
4
5
|
require "pastel"
|
|
6
|
+
require_relative "mado_log"
|
|
5
7
|
|
|
6
8
|
module Tasku
|
|
7
9
|
module TUI
|
|
@@ -10,37 +12,45 @@ module Tasku
|
|
|
10
12
|
# selected task's ID is written to ENV["TASKU_SEL_FILE"] so that Mado
|
|
11
13
|
# button handlers can inject it into commands like `tasku edit <id>`.
|
|
12
14
|
class MadoList
|
|
13
|
-
HINT = " ↑↓ navigate a add e/↵ edit d delete q quit"
|
|
15
|
+
HINT = " ↑↓ navigate a add e/↵ edit d delete s stats q quit"
|
|
14
16
|
|
|
15
|
-
def initialize(tasks, colour_map: {}, bar: {})
|
|
16
|
-
@tasks
|
|
17
|
-
@cursor
|
|
18
|
-
@pastel
|
|
19
|
-
@sel_file
|
|
20
|
-
@running
|
|
17
|
+
def initialize(tasks, colour_map: {}, bar: {}, cols_cfg: {}, project: nil)
|
|
18
|
+
@tasks = tasks
|
|
19
|
+
@cursor = 0
|
|
20
|
+
@pastel = Pastel.new
|
|
21
|
+
@sel_file = ENV["TASKU_SEL_FILE"]
|
|
22
|
+
@running = true
|
|
23
|
+
@inline_cmd = nil
|
|
24
|
+
@list_cmd = project ? "tasku list --project #{Shellwords.shellescape(project)}" : "tasku list"
|
|
21
25
|
|
|
22
26
|
# Pre-render row content once so key-press redraws are cheap.
|
|
23
|
-
|
|
27
|
+
table = Output::Terminal.new.build_mado_rows(tasks, colour_map, bar, cols_cfg)
|
|
28
|
+
@rows = table[:rows]
|
|
29
|
+
@row_width = table[:row_width]
|
|
30
|
+
@header = table[:header]
|
|
31
|
+
|
|
32
|
+
MadoLog.log("init tasks=#{tasks.length} cols_file=#{ENV["TASKU_COLS_FILE"]&.then { |f| File.read(f).strip rescue "?" }}")
|
|
24
33
|
end
|
|
25
34
|
|
|
26
35
|
def run
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
write_selection
|
|
36
|
+
MadoLog.log("run tasks=#{@tasks.length}")
|
|
37
|
+
write_selection unless @tasks.empty?
|
|
30
38
|
|
|
31
39
|
$stdout.print "\e[?25l" # hide cursor
|
|
32
40
|
at_exit { restore_terminal }
|
|
33
|
-
trap("INT") { @running = false }
|
|
41
|
+
trap("INT") { MadoLog.log("SIGINT received"); @running = false }
|
|
34
42
|
|
|
35
43
|
$stdin.raw do |io|
|
|
36
44
|
full_render
|
|
37
45
|
while @running
|
|
38
46
|
prev = @cursor
|
|
39
47
|
key = read_key(io)
|
|
48
|
+
MadoLog.log("key=#{key.inspect} cursor=#{@cursor} tasks=#{@tasks.length}") unless key.nil?
|
|
40
49
|
handle_key(key)
|
|
41
50
|
full_render if @running && @cursor != prev
|
|
42
51
|
end
|
|
43
52
|
end
|
|
53
|
+
MadoLog.log("run exited normally")
|
|
44
54
|
end
|
|
45
55
|
|
|
46
56
|
private
|
|
@@ -54,18 +64,50 @@ module Tasku
|
|
|
54
64
|
|
|
55
65
|
# Full clear + repaint (initial draw or after a command).
|
|
56
66
|
def full_render
|
|
57
|
-
$stdout.print "\e[H\e[
|
|
67
|
+
$stdout.print "\e[3J\e[H\e[2J"
|
|
68
|
+
if @tasks.empty?
|
|
69
|
+
$stdout.print "\r\n #{@pastel.dim("No tasks found.")}\r\n"
|
|
70
|
+
$stdout.print "\r\n#{@pastel.dim(" Press any key to return to list…")}\r\n"
|
|
71
|
+
$stdout.flush
|
|
72
|
+
return
|
|
73
|
+
end
|
|
74
|
+
border = @pastel.dim(" " + "─" * (@row_width + 4))
|
|
75
|
+
$stdout.print "#{border}\r\n"
|
|
76
|
+
$stdout.print " #{@pastel.dim(" ")} #{@header}\r\n" if @header
|
|
77
|
+
$stdout.print "#{border}\r\n"
|
|
58
78
|
@rows.each_with_index do |row, i|
|
|
59
|
-
|
|
60
|
-
|
|
79
|
+
if i == @cursor
|
|
80
|
+
marker = @pastel.bright_blue("[>]")
|
|
81
|
+
# Re-apply the highlight background after every ANSI reset so it
|
|
82
|
+
# persists through pre-rendered colour codes embedded in the row.
|
|
83
|
+
content = " #{marker} #{row}".gsub("\e[0m", "\e[0m\e[48;5;236m")
|
|
84
|
+
$stdout.print "\e[48;5;236m#{content}\e[48;5;236m\e[K\e[0m\r\n"
|
|
85
|
+
else
|
|
86
|
+
marker = @pastel.dim("[ ]")
|
|
87
|
+
$stdout.print " #{marker} #{row}\r\n"
|
|
88
|
+
end
|
|
61
89
|
end
|
|
90
|
+
$stdout.print "#{border}\r\n"
|
|
91
|
+
$stdout.print "#{@pastel.dim(" #{@tasks.length} task(s) found")}\r\n"
|
|
62
92
|
$stdout.print "\r\n#{@pastel.dim(HINT)}\r\n"
|
|
63
93
|
$stdout.flush
|
|
64
94
|
end
|
|
65
95
|
|
|
66
|
-
def read_key(io)
|
|
96
|
+
def read_key(io)
|
|
67
97
|
byte = io.getbyte
|
|
68
98
|
case byte
|
|
99
|
+
when 0x02 # STX — inline exec protocol from Mado buttons.
|
|
100
|
+
# Read the command string until newline; store it and
|
|
101
|
+
# return :inline_exec so handle_key can exec it directly
|
|
102
|
+
# without relying on the shell to pick up buffered input.
|
|
103
|
+
cmd = "".b
|
|
104
|
+
loop do
|
|
105
|
+
b = io.getbyte
|
|
106
|
+
break if b.nil? || b == 0x0A
|
|
107
|
+
cmd << b
|
|
108
|
+
end
|
|
109
|
+
@inline_cmd = cmd.force_encoding("UTF-8").scrub
|
|
110
|
+
:inline_exec
|
|
69
111
|
when 0x1B
|
|
70
112
|
second = io.getbyte
|
|
71
113
|
if second == 0x5B
|
|
@@ -83,11 +125,21 @@ def read_key(io)
|
|
|
83
125
|
when 0x64 then :delete # d
|
|
84
126
|
when 0x65 then :edit # e
|
|
85
127
|
when 0x71 then :q # q
|
|
128
|
+
when 0x73 then :stats # s
|
|
86
129
|
else nil
|
|
87
130
|
end
|
|
88
131
|
end
|
|
89
132
|
|
|
90
133
|
def handle_key(key)
|
|
134
|
+
if @tasks.empty?
|
|
135
|
+
case key
|
|
136
|
+
when :ctrl_c, :escape
|
|
137
|
+
@running = false
|
|
138
|
+
else
|
|
139
|
+
run_command("true") # any other key → exec tasku list
|
|
140
|
+
end
|
|
141
|
+
return
|
|
142
|
+
end
|
|
91
143
|
case key
|
|
92
144
|
when :up
|
|
93
145
|
@cursor = [@cursor - 1, 0].max
|
|
@@ -105,16 +157,65 @@ def read_key(io)
|
|
|
105
157
|
when :delete
|
|
106
158
|
return if @tasks.empty?
|
|
107
159
|
run_command("tasku delete #{@tasks[@cursor].id} -f")
|
|
160
|
+
when :stats
|
|
161
|
+
run_and_show("tasku stats")
|
|
162
|
+
when :inline_exec
|
|
163
|
+
cmd = @inline_cmd.to_s
|
|
164
|
+
MadoLog.log("inline_exec cmd=#{cmd.inspect}")
|
|
165
|
+
if cmd == "SQL"
|
|
166
|
+
run_sql
|
|
167
|
+
else
|
|
168
|
+
run_command(cmd)
|
|
169
|
+
end
|
|
108
170
|
end
|
|
109
171
|
end
|
|
110
172
|
|
|
111
173
|
# Restore the terminal, run a tasku command interactively, then re-exec
|
|
112
174
|
# `tasku list` so the MadoList restarts fresh with updated data.
|
|
113
175
|
def run_command(cmd)
|
|
176
|
+
MadoLog.log("run_command cmd=#{cmd.inspect}")
|
|
114
177
|
@running = false
|
|
115
178
|
restore_terminal
|
|
179
|
+
$stdin.cooked!
|
|
116
180
|
system(cmd)
|
|
117
|
-
|
|
181
|
+
MadoLog.log("run_command finished cmd=#{cmd.inspect} → exec #{@list_cmd}")
|
|
182
|
+
exec(@list_cmd)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Run a non-interactive display command, wait for any keypress via
|
|
186
|
+
# getch (works in cooked mode — no raw-mode setup needed), then
|
|
187
|
+
# re-exec `tasku list`.
|
|
188
|
+
def run_and_show(cmd)
|
|
189
|
+
MadoLog.log("run_and_show cmd=#{cmd.inspect}")
|
|
190
|
+
@running = false
|
|
191
|
+
restore_terminal
|
|
192
|
+
$stdin.cooked!
|
|
193
|
+
system(cmd)
|
|
194
|
+
$stdout.print "\r\n\e[2m Press any key to return to list…\e[0m\r\n"
|
|
195
|
+
$stdout.flush
|
|
196
|
+
$stdin.getch
|
|
197
|
+
MadoLog.log("run_and_show keypress received → exec #{@list_cmd}")
|
|
198
|
+
exec(@list_cmd)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Show a SQL prompt, run the query, wait for keypress, then re-exec list.
|
|
202
|
+
def run_sql
|
|
203
|
+
MadoLog.log("run_sql")
|
|
204
|
+
@running = false
|
|
205
|
+
restore_terminal
|
|
206
|
+
$stdin.cooked!
|
|
207
|
+
$stdout.print "\r\n\e[36m SELECT * FROM tasks WHERE status = 'todo'\e[0m\r\n\r\n"
|
|
208
|
+
$stdout.print "\e[2m SQL> \e[0m"
|
|
209
|
+
$stdout.flush
|
|
210
|
+
q = $stdin.gets&.chomp
|
|
211
|
+
if q && !q.empty?
|
|
212
|
+
system("tasku", "sql", q)
|
|
213
|
+
$stdout.print "\r\n\e[2m Press any key to return to list…\e[0m\r\n"
|
|
214
|
+
$stdout.flush
|
|
215
|
+
$stdin.getch
|
|
216
|
+
end
|
|
217
|
+
MadoLog.log("run_sql done → exec #{@list_cmd}")
|
|
218
|
+
exec(@list_cmd)
|
|
118
219
|
end
|
|
119
220
|
|
|
120
221
|
def restore_terminal
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tasku
|
|
4
|
+
module TUI
|
|
5
|
+
# Minimal append-only logger shared with the Mado Rust process.
|
|
6
|
+
# Both sides write to /tmp/tasku_mado.log so button presses and
|
|
7
|
+
# MadoList state transitions can be correlated in one place.
|
|
8
|
+
module MadoLog
|
|
9
|
+
LOG_FILE = "/tmp/tasku_mado.log"
|
|
10
|
+
|
|
11
|
+
def self.log(msg)
|
|
12
|
+
File.open(LOG_FILE, "a") { |f| f.puts "[#{Time.now.to_i}] tasku: #{msg}" }
|
|
13
|
+
rescue StandardError
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/tasku/version.rb
CHANGED
data/lib/tasku.rb
CHANGED
|
@@ -17,6 +17,12 @@ begin
|
|
|
17
17
|
rescue LoadError
|
|
18
18
|
# TUI not available in this build
|
|
19
19
|
end
|
|
20
|
+
|
|
21
|
+
# Mado integration — ensure Tasku::TUI exists even if the full TUI did not load
|
|
22
|
+
module Tasku
|
|
23
|
+
module TUI; end
|
|
24
|
+
end
|
|
25
|
+
require_relative "tasku/tui/mado_log"
|
|
20
26
|
begin
|
|
21
27
|
require_relative "tasku/tui/mado_list"
|
|
22
28
|
rescue LoadError
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tasku
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tom Dringer
|
|
@@ -95,6 +95,7 @@ files:
|
|
|
95
95
|
- lib/tasku/project.rb
|
|
96
96
|
- lib/tasku/task.rb
|
|
97
97
|
- lib/tasku/tui/mado_list.rb
|
|
98
|
+
- lib/tasku/tui/mado_log.rb
|
|
98
99
|
- lib/tasku/version.rb
|
|
99
100
|
homepage: https://github.com/tomdringer/tasku
|
|
100
101
|
licenses:
|