tasku 0.2.2 → 0.3.1

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: '092769965a41ae651877809552cd6275d60c15793f338d5f185324e86e6bc118'
4
- data.tar.gz: df005e9ccf3967a90320a4fe7d0acf46b03a92bac7986685720e01b305564972
3
+ metadata.gz: d01584cb8583cbcb7623bf5ba3487efa0f9ae428db88b5cd3cf2e6d90393381d
4
+ data.tar.gz: cb79bddf2a6c141a23ee9f0dcf52169767dfb34ab9e86870afeba7d7dc158d33
5
5
  SHA512:
6
- metadata.gz: 7d743c6742d162b3efd017fb460b69f7a0c649d916f6d552790ebafdf998e83ad4c8b1db52ae5d2f8901ed6c7eea8f6423c05009738646a20afb698d95fbfc05
7
- data.tar.gz: 77543fdc9c23db135e3d5da61bbb7ff986cbfffc380967b6d142bd1c842c69fdd3394538ff6f50987caba556270e937deef815537ba82889177c83a6e19b7865
6
+ metadata.gz: 92d51c1a51d85b185ba14e982ccc5eb8a5bbe7bb6f04f74c5dac3d6b0ae0b916174e7afbc09a3230f9525a08650fd8b228a2e4724c20c7f5bdb3a1de71db5ba0
7
+ data.tar.gz: de7d23c5f2032c57ded24ff9d8920001ba7e650538d6a58d0e08d9647d538ee67aa0588d98e29b5986c080ebfe798a5557d02fb100ac53904db29c378fa19df0
data/exe/tasku CHANGED
@@ -3,4 +3,8 @@
3
3
 
4
4
  require "tasku"
5
5
 
6
- Tasku::CLI::App.start(ARGV)
6
+ if ARGV.empty?
7
+ Tasku::CLI::App.start(["help"])
8
+ else
9
+ Tasku::CLI::App.start(ARGV)
10
+ end
data/lib/tasku/cli.rb CHANGED
@@ -13,7 +13,45 @@ module Tasku
13
13
 
14
14
  TAGLINE = "\u30BF\u30B9\u30AF\u30EA\u30B9\u30C8 \u2014 terminal task manager"
15
15
 
16
+ class ConfigApp < Thor
17
+ desc "list", "Show all preferences and their current values"
18
+ def list
19
+ pastel = Pastel.new
20
+ puts ""
21
+ Tasku::Config::VALID_KEYS.each do |key, meta|
22
+ current = Tasku::Config.get(key)
23
+ options_str = meta[:values].map { |v| v == current ? pastel.bold(pastel.green(v)) : pastel.dim(v) }.join(", ")
24
+ puts " #{pastel.bold(key.ljust(20))} #{options_str} #{pastel.dim("— #{meta[:description]}")}"
25
+ end
26
+ puts ""
27
+ end
28
+
29
+ desc "set KEY VALUE", "Set a preference value"
30
+ def set(key, value)
31
+ pastel = Pastel.new
32
+ meta = Tasku::Config::VALID_KEYS[key]
33
+ 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(', ')}")
36
+ end
37
+ Tasku::Config.set(key, value)
38
+ puts pastel.green(" ✓ #{key} set to '#{value}'.")
39
+ end
40
+
41
+ desc "get KEY", "Get the current value of a preference"
42
+ def get(key)
43
+ pastel = Pastel.new
44
+ meta = Tasku::Config::VALID_KEYS[key]
45
+ abort pastel.red("Unknown preference '#{key}'. Run `tasku config list` to see available keys.") unless meta
46
+ puts " #{key}: #{pastel.bold(Tasku::Config.get(key))}"
47
+ end
48
+ end
49
+
16
50
  class App < Thor
51
+ def self.exit_on_failure?
52
+ true
53
+ end
54
+
17
55
  def self.start(args = ARGV, **opts)
18
56
  sql_index = args.index("--sql")
19
57
  if sql_index
@@ -33,6 +71,19 @@ module Tasku
33
71
 
34
72
  class_option :db, type: :string, desc: "Path to SQLite database (default: ~/.tasku/tasks.db)", hide: true
35
73
 
74
+ desc "config SUBCOMMAND", "Manage user preferences"
75
+ subcommand "config", ConfigApp
76
+
77
+ def help(*args)
78
+ if args.empty?
79
+ puts ""
80
+ puts pastel.bold(LOGO)
81
+ puts " #{pastel.bold(TAGLINE)}"
82
+ puts ""
83
+ end
84
+ super
85
+ end
86
+
36
87
  desc "sql QUERY", "Run a raw SQL query against the database"
37
88
  def sql(query)
38
89
  if query.strip.upcase.match?(/\A(SELECT|PRAGMA|EXPLAIN)/)
@@ -123,7 +174,7 @@ module Tasku
123
174
  option :category, type: :string, desc: "Filter by category"
124
175
  option :tags, type: :string, desc: "Filter by tag (comma-separated)"
125
176
  option :overdue, type: :boolean, desc: "Show only overdue tasks"
126
- option :sort, type: :string, desc: "Sort by: name, priority, due, status, created"
177
+ option :sort, type: :string, desc: "Sort by: id, name, priority, due, status, created"
127
178
  option :order, type: :string, desc: "Order: asc, desc", default: "asc"
128
179
  def list
129
180
  dataset = Task.dataset
@@ -150,20 +201,22 @@ module Tasku
150
201
  when "priority" then Sequel.case(Task::VALID_PRIORITIES.each_with_index.to_h, 999, :priority)
151
202
  when "due" then :due_day
152
203
  when "status" then Sequel.case(Task::VALID_STATUSES.each_with_index.to_h, 999, :status)
153
- else :created_at
204
+ when "created" then :created_at
205
+ else :id
154
206
  end
155
207
 
156
208
  order = options[:order] == "desc" ? Sequel.desc(sort_col) : Sequel.asc(sort_col)
157
209
  dataset = dataset.order(order)
158
210
 
159
211
  tasks = dataset.all
160
- terminal.render_list(tasks)
212
+ 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)
161
214
  end
162
215
 
163
216
  desc "show ID", "Show task details"
164
217
  def show(id)
165
218
  task = find_task(id)
166
- terminal.render_show(task)
219
+ terminal.render_show(task, colour_map: Project.colour_map)
167
220
  end
168
221
 
169
222
  desc "edit ID", "Edit a task"
@@ -241,25 +294,48 @@ module Tasku
241
294
  desc "stats", "Show task statistics"
242
295
  def stats
243
296
  tasks = Task.dataset.all
244
- terminal.render_stats(tasks) if tasks
297
+ terminal.render_stats(tasks, colour_map: Project.colour_map) if tasks
245
298
  end
246
299
 
247
300
  desc "projects", "List all projects"
248
301
  def projects
249
- projects = Task.dataset.select(:project).where(Sequel.~(project: nil)).distinct.order(:project).map(:project)
250
- if projects.empty?
302
+ project_names = Task.dataset.select(:project).where(Sequel.~(project: nil)).distinct.order(:project).map(:project)
303
+ if project_names.empty?
251
304
  puts pastel.yellow(" No projects found.")
252
305
  return
253
306
  end
254
307
 
308
+ colour_map = Project.colour_map
255
309
  puts ""
256
- projects.each do |p|
310
+ project_names.each do |p|
257
311
  count = Task.where(project: p).count
258
- puts " #{pastel.bold(p)} #{pastel.dim("(#{count} task(s))")}"
312
+ colour = colour_map[p]
313
+ swatch = colour ? "#{hex_colour_str("█", colour)} " : " "
314
+ name_str = colour ? hex_bold_str(p, colour) : pastel.bold(p)
315
+ puts " #{swatch}#{name_str} #{pastel.dim("(#{count} task(s))")}"
259
316
  end
260
317
  puts ""
261
318
  end
262
319
 
320
+ desc "colour PROJECT HEX", "Set a project's display colour (e.g. #FF5733). Omit HEX to clear."
321
+ def colour(project, hex = nil)
322
+ if hex.nil?
323
+ proj = Project[project]
324
+ if proj
325
+ proj.update(colour: nil)
326
+ puts pastel.green(" Colour cleared for project '#{project}'.")
327
+ else
328
+ puts pastel.yellow(" No colour set for project '#{project}'.")
329
+ end
330
+ return
331
+ end
332
+
333
+ validated = validate_hex!(hex)
334
+ Project.find_or_create(name: project).update(colour: validated)
335
+ swatch = hex_colour_str("█", validated)
336
+ puts " #{swatch} Colour #{pastel.bold(validated)} set for project '#{pastel.bold(project)}'."
337
+ end
338
+
263
339
  desc "categories", "List all categories"
264
340
  def categories
265
341
  categories = Task.dataset.select(:category).where(Sequel.~(category: nil)).distinct.order(:category).map(:category)
@@ -428,6 +504,24 @@ module Tasku
428
504
  "archived" => :dim
429
505
  }.freeze
430
506
 
507
+ def validate_hex!(hex)
508
+ hex = hex.start_with?("#") ? hex : "##{hex}"
509
+ unless hex.match?(/\A#[0-9a-fA-F]{6}\z/)
510
+ abort pastel.red("Invalid hex colour '#{hex}'. Use format #RRGGBB.")
511
+ end
512
+ hex
513
+ end
514
+
515
+ def hex_colour_str(text, hex)
516
+ r, g, b = hex.delete("#").scan(/../).map { |c| c.to_i(16) }
517
+ "\e[38;2;#{r};#{g};#{b}m#{text}\e[0m"
518
+ end
519
+
520
+ def hex_bold_str(text, hex)
521
+ r, g, b = hex.delete("#").scan(/../).map { |c| c.to_i(16) }
522
+ "\e[1;38;2;#{r};#{g};#{b}m#{text}\e[0m"
523
+ end
524
+
431
525
  def colour_cell(col, padded_val, row)
432
526
  case col.to_s
433
527
  when "priority"
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Tasku
7
+ class Config
8
+ CONFIG_PATH = File.join(Dir.home, ".tasku", "config.json")
9
+
10
+ VALID_KEYS = {
11
+ "list_spacing" => {
12
+ values: %w[compact spacious],
13
+ default: "compact",
14
+ description: "Row spacing in task list"
15
+ },
16
+ "bar_project" => {
17
+ values: %w[on off],
18
+ default: "on",
19
+ description: "Show project colour segment in row bar"
20
+ },
21
+ "bar_priority" => {
22
+ values: %w[on off],
23
+ default: "on",
24
+ description: "Show priority colour segment in row bar"
25
+ },
26
+ "bar_status" => {
27
+ values: %w[on off],
28
+ default: "on",
29
+ description: "Show status colour segment in row bar"
30
+ }
31
+ }.freeze
32
+
33
+ def self.get(key)
34
+ all[key] || VALID_KEYS.dig(key, :default)
35
+ end
36
+
37
+ def self.set(key, value)
38
+ current = all
39
+ current[key] = value
40
+ FileUtils.mkdir_p(File.dirname(CONFIG_PATH))
41
+ File.write(CONFIG_PATH, JSON.pretty_generate(current))
42
+ end
43
+
44
+ def self.all
45
+ return {} unless File.exist?(CONFIG_PATH)
46
+
47
+ JSON.parse(File.read(CONFIG_PATH))
48
+ rescue JSON::ParserError
49
+ {}
50
+ end
51
+ end
52
+ end
@@ -42,6 +42,11 @@ module Tasku
42
42
  if db.table_exists?(:tasks) && !db.schema(:tasks).map(&:first).include?(:code)
43
43
  db.alter_table(:tasks) { add_column :code, String }
44
44
  end
45
+
46
+ db.create_table? :projects do
47
+ String :name, primary_key: true
48
+ String :colour
49
+ end
45
50
  end
46
51
  end
47
52
  end
@@ -5,6 +5,23 @@ require "pastel"
5
5
  module Tasku
6
6
  module Output
7
7
  class Terminal
8
+ PRIORITY_HEX = {
9
+ "none" => "#6B7280",
10
+ "low" => "#06B6D4",
11
+ "medium" => "#EAB308",
12
+ "high" => "#EF4444",
13
+ "urgent" => "#E879F9"
14
+ }.freeze
15
+
16
+ STATUS_HEX = {
17
+ "backlog" => "#6B7280",
18
+ "todo" => "#3B82F6",
19
+ "in_progress" => "#EAB308",
20
+ "done" => "#22C55E",
21
+ "cancelled" => "#EF4444",
22
+ "archived" => "#6B7280"
23
+ }.freeze
24
+
8
25
  PRIORITY_STYLES = {
9
26
  "none" => { color: :dim, symbol: " " },
10
27
  "low" => { color: :cyan, symbol: "↓" },
@@ -26,16 +43,16 @@ module Tasku
26
43
 
27
44
  def initialize
28
45
  @pastel = Pastel.new
29
- @term_width = [terminal_width, 80].min
46
+ @term_width = terminal_width
30
47
  end
31
48
 
32
- def render_list(tasks)
49
+ def render_list(tasks, colour_map: {}, spacing: "compact", bar: {})
33
50
  if tasks.empty?
34
51
  puts @pastel.yellow(" No tasks found.")
35
52
  return
36
53
  end
37
54
 
38
- rows = tasks.map { |t| build_columns(t) }
55
+ rows = tasks.map { |t| build_columns(t, colour_map, bar) }
39
56
  col_widths = compute_widths(rows)
40
57
  total = col_widths.sum + (COL_SEP.length * (col_widths.length - 1))
41
58
 
@@ -44,12 +61,13 @@ module Tasku
44
61
  rows.each do |cols|
45
62
  line = cols.each_with_index.map { |c, i| c.to_s.ljust(col_widths[i]) }.join(COL_SEP)
46
63
  puts " #{line}"
64
+ puts "" if spacing == "spacious"
47
65
  end
48
66
  puts @pastel.dim(" #{"─" * total}")
49
67
  puts @pastel.dim(" #{tasks.length} task(s) found")
50
68
  end
51
69
 
52
- def render_show(task)
70
+ def render_show(task, colour_map: {})
53
71
  puts ""
54
72
  puts @pastel.bold(" Task ##{task.id}")
55
73
  puts @pastel.dim(" #{"─" * 60}")
@@ -57,7 +75,7 @@ module Tasku
57
75
  fields = [
58
76
  ["Name", @pastel.bold(task.name)],
59
77
  ["Description", task.description ? @pastel.dim(task.description) : @pastel.dim("—")],
60
- ["Project", task.project || @pastel.dim("—")],
78
+ ["Project", project_str(task.project, colour_map)],
61
79
  ["Category", task.category || @pastel.dim("—")],
62
80
  ["Priority", priority_tag(task.priority)],
63
81
  ["Status", status_tag(task.status)],
@@ -90,7 +108,7 @@ module Tasku
90
108
  puts @pastel.red(" ✗ Task ##{task.id} deleted") + " — #{@pastel.bold(task.name)}"
91
109
  end
92
110
 
93
- def render_stats(tasks)
111
+ def render_stats(tasks, colour_map: {})
94
112
  puts ""
95
113
  puts @pastel.bold(" Task Statistics")
96
114
  puts @pastel.dim(" #{"─" * 60}")
@@ -98,12 +116,13 @@ module Tasku
98
116
  total = tasks.length
99
117
  by_status = tasks.group_by(&:status).transform_values(&:length)
100
118
  by_priority = tasks.group_by(&:priority).transform_values(&:length)
119
+ by_project = tasks.group_by(&:project).transform_values(&:length)
101
120
  overdue = tasks.count(&:overdue?)
102
- projects = tasks.map(&:project).compact.uniq.length
121
+ project_count = tasks.map(&:project).compact.uniq.length
103
122
 
104
123
  puts " #{@pastel.dim("Total tasks:".ljust(20))} #{total}"
105
124
  puts " #{@pastel.dim("Overdue:".ljust(20))} #{overdue.positive? ? @pastel.red(overdue.to_s) : @pastel.green("0")}"
106
- puts " #{@pastel.dim("Projects:".ljust(20))} #{projects}"
125
+ puts " #{@pastel.dim("Projects:".ljust(20))} #{project_count}"
107
126
  puts ""
108
127
 
109
128
  puts " #{@pastel.dim("By Status:")}"
@@ -122,16 +141,42 @@ module Tasku
122
141
 
123
142
  puts " #{priority_tag(p)} #{@pastel.send(PRIORITY_STYLES.dig(p, :color) || :dim, count.to_s.rjust(3))}"
124
143
  end
144
+
145
+ puts ""
146
+ puts " #{@pastel.dim("By Project:")}"
147
+ by_project.sort_by { |_, count| -count }.each do |name, count|
148
+ label = name || @pastel.dim("(none)")
149
+ coloured_name = name && colour_map[name] ? hex_colour_str(name, colour_map[name]) : label
150
+ padding = " " * [20 - strip_ansi(coloured_name).length, 0].max
151
+ puts " #{coloured_name}#{padding} #{@pastel.dim(count.to_s.rjust(3))}"
152
+ end
125
153
  puts ""
126
154
  end
127
155
 
128
156
  private
129
157
 
130
- def build_columns(task)
158
+ def build_columns(task, colour_map = {}, bar = {})
131
159
  id_val = task.code && !task.code.empty? ? "#{task.code}-#{task.id}" : task.id.to_s
132
- id_str = @pastel.dim(id_val)
160
+
161
+ segments = []
162
+ if bar["bar_project"] != "off"
163
+ proj_colour = task.project && colour_map[task.project]
164
+ segments << (proj_colour ? hex_colour_str("█", proj_colour) : @pastel.dim("█"))
165
+ end
166
+ if bar["bar_priority"] != "off"
167
+ segments << hex_colour_str("█", PRIORITY_HEX[task.priority] || PRIORITY_HEX["none"])
168
+ end
169
+ if bar["bar_status"] != "off"
170
+ segments << hex_colour_str("█", STATUS_HEX[task.status] || STATUS_HEX["backlog"])
171
+ end
172
+
173
+ id_str = if segments.any?
174
+ "#{segments.join} #{@pastel.dim(id_val)}"
175
+ else
176
+ @pastel.dim(id_val)
177
+ end
133
178
  name_str = @pastel.bold(task.name)
134
- proj_str = task.project || @pastel.dim("—")
179
+ proj_str = project_str(task.project, colour_map)
135
180
  prio_str = priority_tag(task.priority)
136
181
  stat_str = status_tag(task.status)
137
182
  due_str = due_cell(task)
@@ -181,6 +226,21 @@ module Tasku
181
226
  @pastel.send(style[:color], "#{style[:symbol]} #{label}")
182
227
  end
183
228
 
229
+ def hex_colour_str(text, hex)
230
+ r, g, b = hex.delete("#").scan(/../).map { |c| c.to_i(16) }
231
+ "\e[38;2;#{r};#{g};#{b}m#{text}\e[0m"
232
+ end
233
+
234
+ def project_str(project, colour_map)
235
+ return @pastel.dim("—") unless project
236
+
237
+ colour = colour_map[project]
238
+ return project unless colour
239
+
240
+ r, g, b = colour.delete("#").scan(/../).map { |c| c.to_i(16) }
241
+ "\e[38;2;#{r};#{g};#{b}m#{project}\e[0m"
242
+ end
243
+
184
244
  def due_cell(task)
185
245
  return @pastel.dim("—") unless task.due_day
186
246
 
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "database"
4
+
5
+ module Tasku
6
+ class Project < Sequel::Model(:projects)
7
+ unrestrict_primary_key
8
+
9
+ def self.colour_map
10
+ all.each_with_object({}) { |p, h| h[p.name] = p.colour }
11
+ end
12
+ end
13
+ 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.2.2"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/tasku.rb CHANGED
@@ -8,6 +8,12 @@ require_relative "tasku/version"
8
8
  require_relative "tasku/database"
9
9
  Tasku::Database.connect
10
10
  require_relative "tasku/task"
11
+ require_relative "tasku/project"
12
+ require_relative "tasku/config"
11
13
  require_relative "tasku/output/terminal"
12
14
  require_relative "tasku/cli"
13
- require_relative "tasku/tui/app"
15
+ begin
16
+ require_relative "tasku/tui/app"
17
+ rescue LoadError
18
+ # TUI not available in this build
19
+ 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.2.2
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Dringer
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-06-29 00:00:00.000000000 Z
10
+ date: 2026-08-29 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: thor
@@ -89,8 +89,10 @@ files:
89
89
  - exe/tasku
90
90
  - lib/tasku.rb
91
91
  - lib/tasku/cli.rb
92
+ - lib/tasku/config.rb
92
93
  - lib/tasku/database.rb
93
94
  - lib/tasku/output/terminal.rb
95
+ - lib/tasku/project.rb
94
96
  - lib/tasku/task.rb
95
97
  - lib/tasku/version.rb
96
98
  homepage: https://github.com/tomdringer/tasku