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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74f35cfb28f9ec9b5b7cc0ce5a0a62028d7162faa7ed1a8e9efedb4f63fa2638
4
- data.tar.gz: a064d7fa64823a9f3362e9220b4bc69311f2c20f21e10e6ae85501ed5f5f92de
3
+ metadata.gz: cd2fbd40e27817e56591b3a6ff769c59b109de5e34f2a09b416e38008de1428f
4
+ data.tar.gz: 787ea03c2a16d886bf62869d8e5174f28e5be667822e113fc248b32135e175bd
5
5
  SHA512:
6
- metadata.gz: 93955913ae30ae85a403b3e11977739d64eac51c1fbebfeb97f07b6beac7149bba991e7da74f067778838f7fdda455ebfe6d74f273cc83e5239daaedc697be48
7
- data.tar.gz: 0033c68f813e016cb2891fb7ed63e29d736f14460f18617fe021dbeeace9dc46c08639fc25f8764c1ccd0961edea2ad151225677ca7fb561e7214531706e338d
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
- options_str = meta[:values].map { |v| v == current ? pastel.bold(pastel.green(v)) : pastel.dim(v) }.join(", ")
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
- unless meta[:values].include?(value)
35
- abort pastel.red("Invalid value '#{value}' for '#{key}'. Valid: #{meta[:values].join(', ')}")
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, type: :boolean, desc: "Show only overdue tasks"
177
- option :sort, type: :string, desc: "Sort by: id, name, priority, due, status, created"
178
- option :order, type: :string, desc: "Order: asc, desc", default: "asc"
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 = option_edit
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
- tasks = Task.dataset.all
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
@@ -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.ljust(col_widths[i]) }.join(COL_SEP)
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 (4 extra chars) when computing widths.
158
- def build_mado_rows(tasks, colour_map = {}, bar = {})
159
- return [] if tasks.empty?
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 = [@term_width - 4, 40].max
169
+ @term_width = [orig_width - 6, 40].max
163
170
 
164
- col_rows = tasks.map { |t| build_columns(t, colour_map, bar) }
165
- col_widths = compute_widths(col_rows)
166
- result = col_rows.map do |cols|
167
- cols.each_with_index.map { |c, i| ansi_ljust(c.to_s, col_widths[i]) }.join(COL_SEP)
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
- name_str = @pastel.bold(task.name)
197
- proj_str = project_str(task.project, colour_map)
198
- prio_str = priority_tag(task.priority)
199
- stat_str = status_tag(task.status)
200
- due_str = due_cell(task)
201
- [id_str, name_str, proj_str, prio_str, stat_str, due_str]
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
- if maxes[1] > available_name || maxes[1] < min_name
213
- name_width = [available_name, min_name].max
214
- rows.each_with_index do |cols, _ri|
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
- cols[1] = "#{raw_str[0..name_width - 2]}#{@pastel.dim("…")}"
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
- maxes[1] = name_width
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
@@ -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 = tasks
17
- @cursor = 0
18
- @pastel = Pastel.new
19
- @sel_file = ENV["TASKU_SEL_FILE"]
20
- @running = true
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
- @rows = Output::Terminal.new.build_mado_rows(tasks, colour_map, bar)
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
- return if @tasks.empty?
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[J" # cursor home, clear to end of screen
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
- marker = i == @cursor ? @pastel.bright_blue("[>]") : @pastel.dim("[ ]")
60
- $stdout.print " #{marker} #{row}\r\n"
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
- exec("tasku list")
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tasku
4
- VERSION = "0.3.9"
4
+ VERSION = "0.4.0"
5
5
  end
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.3.9
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: