tasku 0.3.1 → 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 +348 -10
- data/lib/tasku/config.rb +103 -0
- data/lib/tasku/database.rb +12 -0
- data/lib/tasku/output/terminal.rb +179 -22
- data/lib/tasku/task.rb +6 -0
- data/lib/tasku/tui/mado_list.rb +228 -0
- data/lib/tasku/tui/mado_log.rb +18 -0
- data/lib/tasku/version.rb +1 -1
- data/lib/tasku.rb +11 -0
- metadata +5 -3
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,7 +458,22 @@ 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") }
|
|
213
|
-
|
|
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
|
+
}
|
|
472
|
+
if ENV["MADO"] == "1"
|
|
473
|
+
Tasku::TUI::MadoList.new(tasks, colour_map: Project.colour_map, bar: bar, cols_cfg: cols_cfg, project: options[:project]).run
|
|
474
|
+
else
|
|
475
|
+
terminal.render_list(tasks, colour_map: Project.colour_map, spacing: Config.get("list_spacing"), bar: bar, cols_cfg: cols_cfg)
|
|
476
|
+
end
|
|
214
477
|
end
|
|
215
478
|
|
|
216
479
|
desc "show ID", "Show task details"
|
|
@@ -233,10 +496,19 @@ module Tasku
|
|
|
233
496
|
option :hours, type: :numeric, desc: "Estimated hours"
|
|
234
497
|
option :code, type: :string, desc: "Code"
|
|
235
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
|
|
236
500
|
def edit(id)
|
|
237
501
|
task = find_task(id)
|
|
238
502
|
|
|
239
|
-
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
|
|
240
512
|
if attrs.empty? && !options[:clear]
|
|
241
513
|
puts pastel.yellow("No changes specified. Use --help to see available options.")
|
|
242
514
|
return
|
|
@@ -292,8 +564,11 @@ module Tasku
|
|
|
292
564
|
end
|
|
293
565
|
|
|
294
566
|
desc "stats", "Show task statistics"
|
|
567
|
+
option :project, type: :string, desc: "Filter by project"
|
|
295
568
|
def stats
|
|
296
|
-
|
|
569
|
+
dataset = Task.dataset
|
|
570
|
+
dataset = dataset.where(project: options[:project]) if options[:project]
|
|
571
|
+
tasks = dataset.all
|
|
297
572
|
terminal.render_stats(tasks, colour_map: Project.colour_map) if tasks
|
|
298
573
|
end
|
|
299
574
|
|
|
@@ -433,6 +708,69 @@ module Tasku
|
|
|
433
708
|
tags: (tags unless tags&.empty?),
|
|
434
709
|
estimated_hours: hours
|
|
435
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
|
|
436
774
|
end
|
|
437
775
|
|
|
438
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
|
|
@@ -153,9 +157,56 @@ module Tasku
|
|
|
153
157
|
puts ""
|
|
154
158
|
end
|
|
155
159
|
|
|
160
|
+
# Returns an array of pre-formatted row strings for MadoList.
|
|
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?
|
|
167
|
+
|
|
168
|
+
orig_width = @term_width
|
|
169
|
+
@term_width = [orig_width - 6, 40].max
|
|
170
|
+
|
|
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]
|
|
197
|
+
end
|
|
198
|
+
header = labels.each_with_index
|
|
199
|
+
.map { |lbl, i| ansi_ljust(@pastel.dim(lbl), col_widths[i]) }
|
|
200
|
+
.join(dim_sep)
|
|
201
|
+
|
|
202
|
+
row_width = col_widths.sum + COL_SEP.length * (col_widths.length - 1)
|
|
203
|
+
@term_width = orig_width
|
|
204
|
+
{ rows: result, row_width: row_width, header: header }
|
|
205
|
+
end
|
|
206
|
+
|
|
156
207
|
private
|
|
157
208
|
|
|
158
|
-
def build_columns(task, colour_map = {}, bar = {})
|
|
209
|
+
def build_columns(task, colour_map = {}, bar = {}, cols_cfg = {})
|
|
159
210
|
id_val = task.code && !task.code.empty? ? "#{task.code}-#{task.id}" : task.id.to_s
|
|
160
211
|
|
|
161
212
|
segments = []
|
|
@@ -175,41 +226,140 @@ module Tasku
|
|
|
175
226
|
else
|
|
176
227
|
@pastel.dim(id_val)
|
|
177
228
|
end
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
184
238
|
end
|
|
185
239
|
|
|
186
|
-
def compute_widths(rows)
|
|
240
|
+
def compute_widths(rows, cols_cfg = {})
|
|
187
241
|
raw = rows.map { |cols| cols.map { |c| strip_ansi(c.to_s).length } }
|
|
188
242
|
maxes = raw.transpose.map(&:max)
|
|
189
|
-
sep_total = COL_SEP.length * (maxes.length - 1)
|
|
190
|
-
fixed_cols = maxes[0] + maxes[2] + maxes[3] + maxes[4] + maxes[5]
|
|
191
|
-
available_name = @term_width - fixed_cols - sep_total - 4
|
|
192
|
-
min_name = 20
|
|
193
243
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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|
|
|
197
275
|
raw_str = strip_ansi(cols[1].to_s)
|
|
198
276
|
if raw_str.length > name_width
|
|
199
|
-
|
|
277
|
+
keep = [name_width - 1, 0].max
|
|
278
|
+
cols[1] = keep > 0 ? "#{raw_str[0, keep]}#{@pastel.dim("…")}" : ""
|
|
200
279
|
end
|
|
201
280
|
end
|
|
202
|
-
|
|
281
|
+
else
|
|
282
|
+
name_width = maxes[1]
|
|
203
283
|
end
|
|
284
|
+
maxes[1] = name_width
|
|
204
285
|
|
|
205
286
|
maxes
|
|
206
287
|
end
|
|
207
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]
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
maxes
|
|
312
|
+
end
|
|
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
|
+
|
|
208
343
|
def strip_ansi(str)
|
|
209
344
|
str.gsub(/\e\[[0-9;]*m/, "")
|
|
210
345
|
end
|
|
211
346
|
|
|
347
|
+
# Pad an ANSI-coloured string to `width` visible characters.
|
|
348
|
+
# String#ljust counts bytes (including escape codes), so we compute
|
|
349
|
+
# the visible length ourselves and append plain spaces for the deficit.
|
|
350
|
+
def ansi_ljust(str, width)
|
|
351
|
+
deficit = width - strip_ansi(str).length
|
|
352
|
+
deficit > 0 ? str + (" " * deficit) : str
|
|
353
|
+
end
|
|
354
|
+
|
|
212
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 }
|
|
213
363
|
IO.console&.winsize&.[](1) || 80
|
|
214
364
|
rescue
|
|
215
365
|
80
|
|
@@ -231,6 +381,10 @@ module Tasku
|
|
|
231
381
|
"\e[38;2;#{r};#{g};#{b}m#{text}\e[0m"
|
|
232
382
|
end
|
|
233
383
|
|
|
384
|
+
def category_str(category)
|
|
385
|
+
category && !category.empty? ? @pastel.cyan(category) : @pastel.dim("—")
|
|
386
|
+
end
|
|
387
|
+
|
|
234
388
|
def project_str(project, colour_map)
|
|
235
389
|
return @pastel.dim("—") unless project
|
|
236
390
|
|
|
@@ -248,6 +402,9 @@ module Tasku
|
|
|
248
402
|
day = task.due_day.strftime("%b %d")
|
|
249
403
|
if task.overdue?
|
|
250
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)")
|
|
251
408
|
elsif diff.zero?
|
|
252
409
|
@pastel.yellow("#{day} (today)")
|
|
253
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
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
require "pastel"
|
|
6
|
+
require_relative "mado_log"
|
|
7
|
+
|
|
8
|
+
module Tasku
|
|
9
|
+
module TUI
|
|
10
|
+
# Interactive cursor-based task list for the Mado terminal multiplexer.
|
|
11
|
+
# Triggered when ENV["MADO"] == "1". Arrow keys move the cursor; the
|
|
12
|
+
# selected task's ID is written to ENV["TASKU_SEL_FILE"] so that Mado
|
|
13
|
+
# button handlers can inject it into commands like `tasku edit <id>`.
|
|
14
|
+
class MadoList
|
|
15
|
+
HINT = " ↑↓ navigate a add e/↵ edit d delete s stats q quit"
|
|
16
|
+
|
|
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"
|
|
25
|
+
|
|
26
|
+
# Pre-render row content once so key-press redraws are cheap.
|
|
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 "?" }}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run
|
|
36
|
+
MadoLog.log("run tasks=#{@tasks.length}")
|
|
37
|
+
write_selection unless @tasks.empty?
|
|
38
|
+
|
|
39
|
+
$stdout.print "\e[?25l" # hide cursor
|
|
40
|
+
at_exit { restore_terminal }
|
|
41
|
+
trap("INT") { MadoLog.log("SIGINT received"); @running = false }
|
|
42
|
+
|
|
43
|
+
$stdin.raw do |io|
|
|
44
|
+
full_render
|
|
45
|
+
while @running
|
|
46
|
+
prev = @cursor
|
|
47
|
+
key = read_key(io)
|
|
48
|
+
MadoLog.log("key=#{key.inspect} cursor=#{@cursor} tasks=#{@tasks.length}") unless key.nil?
|
|
49
|
+
handle_key(key)
|
|
50
|
+
full_render if @running && @cursor != prev
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
MadoLog.log("run exited normally")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def write_selection
|
|
59
|
+
return unless @sel_file
|
|
60
|
+
File.write(@sel_file, @tasks[@cursor].id.to_s)
|
|
61
|
+
rescue StandardError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Full clear + repaint (initial draw or after a command).
|
|
66
|
+
def full_render
|
|
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"
|
|
78
|
+
@rows.each_with_index do |row, i|
|
|
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
|
|
89
|
+
end
|
|
90
|
+
$stdout.print "#{border}\r\n"
|
|
91
|
+
$stdout.print "#{@pastel.dim(" #{@tasks.length} task(s) found")}\r\n"
|
|
92
|
+
$stdout.print "\r\n#{@pastel.dim(HINT)}\r\n"
|
|
93
|
+
$stdout.flush
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def read_key(io)
|
|
97
|
+
byte = io.getbyte
|
|
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
|
|
111
|
+
when 0x1B
|
|
112
|
+
second = io.getbyte
|
|
113
|
+
if second == 0x5B
|
|
114
|
+
case io.getbyte
|
|
115
|
+
when 0x41 then :up
|
|
116
|
+
when 0x42 then :down
|
|
117
|
+
else nil
|
|
118
|
+
end
|
|
119
|
+
else
|
|
120
|
+
:escape
|
|
121
|
+
end
|
|
122
|
+
when 0x03 then :ctrl_c
|
|
123
|
+
when 0x0D, 0x0A then :enter # Return / Enter
|
|
124
|
+
when 0x61 then :add # a
|
|
125
|
+
when 0x64 then :delete # d
|
|
126
|
+
when 0x65 then :edit # e
|
|
127
|
+
when 0x71 then :q # q
|
|
128
|
+
when 0x73 then :stats # s
|
|
129
|
+
else nil
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
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
|
|
143
|
+
case key
|
|
144
|
+
when :up
|
|
145
|
+
@cursor = [@cursor - 1, 0].max
|
|
146
|
+
write_selection
|
|
147
|
+
when :down
|
|
148
|
+
@cursor = [@cursor + 1, @tasks.length - 1].min
|
|
149
|
+
write_selection
|
|
150
|
+
when :q, :ctrl_c, :escape
|
|
151
|
+
@running = false
|
|
152
|
+
when :add
|
|
153
|
+
run_command("tasku add -i")
|
|
154
|
+
when :edit, :enter
|
|
155
|
+
return if @tasks.empty?
|
|
156
|
+
run_command("tasku edit #{@tasks[@cursor].id} -i")
|
|
157
|
+
when :delete
|
|
158
|
+
return if @tasks.empty?
|
|
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
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Restore the terminal, run a tasku command interactively, then re-exec
|
|
174
|
+
# `tasku list` so the MadoList restarts fresh with updated data.
|
|
175
|
+
def run_command(cmd)
|
|
176
|
+
MadoLog.log("run_command cmd=#{cmd.inspect}")
|
|
177
|
+
@running = false
|
|
178
|
+
restore_terminal
|
|
179
|
+
$stdin.cooked!
|
|
180
|
+
system(cmd)
|
|
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)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def restore_terminal
|
|
222
|
+
$stdout.print "\e[?25h\e[0m"
|
|
223
|
+
rescue IOError
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -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,3 +17,14 @@ 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"
|
|
26
|
+
begin
|
|
27
|
+
require_relative "tasku/tui/mado_list"
|
|
28
|
+
rescue LoadError
|
|
29
|
+
# Mado integration not available
|
|
30
|
+
end
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
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
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: thor
|
|
@@ -94,6 +94,8 @@ files:
|
|
|
94
94
|
- lib/tasku/output/terminal.rb
|
|
95
95
|
- lib/tasku/project.rb
|
|
96
96
|
- lib/tasku/task.rb
|
|
97
|
+
- lib/tasku/tui/mado_list.rb
|
|
98
|
+
- lib/tasku/tui/mado_log.rb
|
|
97
99
|
- lib/tasku/version.rb
|
|
98
100
|
homepage: https://github.com/tomdringer/tasku
|
|
99
101
|
licenses:
|
|
@@ -116,7 +118,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
116
118
|
- !ruby/object:Gem::Version
|
|
117
119
|
version: '0'
|
|
118
120
|
requirements: []
|
|
119
|
-
rubygems_version:
|
|
121
|
+
rubygems_version: 4.0.20
|
|
120
122
|
specification_version: 4
|
|
121
123
|
summary: タスクリスト — a beautiful terminal task manager
|
|
122
124
|
test_files: []
|