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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d01584cb8583cbcb7623bf5ba3487efa0f9ae428db88b5cd3cf2e6d90393381d
4
- data.tar.gz: cb79bddf2a6c141a23ee9f0dcf52169767dfb34ab9e86870afeba7d7dc158d33
3
+ metadata.gz: cd2fbd40e27817e56591b3a6ff769c59b109de5e34f2a09b416e38008de1428f
4
+ data.tar.gz: 787ea03c2a16d886bf62869d8e5174f28e5be667822e113fc248b32135e175bd
5
5
  SHA512:
6
- metadata.gz: 92d51c1a51d85b185ba14e982ccc5eb8a5bbe7bb6f04f74c5dac3d6b0ae0b916174e7afbc09a3230f9525a08650fd8b228a2e4724c20c7f5bdb3a1de71db5ba0
7
- data.tar.gz: de7d23c5f2032c57ded24ff9d8920001ba7e650538d6a58d0e08d9647d538ee67aa0588d98e29b5986c080ebfe798a5557d02fb100ac53904db29c378fa19df0
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,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
- terminal.render_list(tasks, colour_map: Project.colour_map, spacing: Config.get("list_spacing"), bar: bar)
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 = 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
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
- tasks = Task.dataset.all
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
@@ -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
@@ -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
- name_str = @pastel.bold(task.name)
179
- proj_str = project_str(task.project, colour_map)
180
- prio_str = priority_tag(task.priority)
181
- stat_str = status_tag(task.status)
182
- due_str = due_cell(task)
183
- [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
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
- if maxes[1] > available_name || maxes[1] < min_name
195
- name_width = [available_name, min_name].max
196
- 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|
197
275
  raw_str = strip_ansi(cols[1].to_s)
198
276
  if raw_str.length > name_width
199
- 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("…")}" : ""
200
279
  end
201
280
  end
202
- maxes[1] = name_width
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tasku
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
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.3.1
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: 2026-08-29 00:00:00.000000000 Z
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: 3.6.2
121
+ rubygems_version: 4.0.20
120
122
  specification_version: 4
121
123
  summary: タスクリスト — a beautiful terminal task manager
122
124
  test_files: []