tasku 0.2.2 → 0.3.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: '092769965a41ae651877809552cd6275d60c15793f338d5f185324e86e6bc118'
4
- data.tar.gz: df005e9ccf3967a90320a4fe7d0acf46b03a92bac7986685720e01b305564972
3
+ metadata.gz: 7cbf11fec02c7ece6e6d6599f6a7b6e1dc837cae43f9683a197e63c3ca37cc58
4
+ data.tar.gz: 98582ff1f4d450d8d5d669e245aca8fc9ae7704fddbe0c661852b6f2a82020b0
5
5
  SHA512:
6
- metadata.gz: 7d743c6742d162b3efd017fb460b69f7a0c649d916f6d552790ebafdf998e83ad4c8b1db52ae5d2f8901ed6c7eea8f6423c05009738646a20afb698d95fbfc05
7
- data.tar.gz: 77543fdc9c23db135e3d5da61bbb7ff986cbfffc380967b6d142bd1c842c69fdd3394538ff6f50987caba556270e937deef815537ba82889177c83a6e19b7865
6
+ metadata.gz: caecee96bc181eb9ca32c6fa67534ca82950cfba81c78c26d6e318f461bdc925d73446d94e88fbe91ebf15d69c3c82e0f281ab81770b4307d217e54781008e6d
7
+ data.tar.gz: 13e9c104b1c50404af47972a9012e731d0aec773ebdb776e6122eecf466f30e7c7f7642889df6b41685eeeb04f3d0ed2876fa574bfed92a2d1e7f859df3e7562
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,6 +13,40 @@ 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
17
51
  def self.start(args = ARGV, **opts)
18
52
  sql_index = args.index("--sql")
@@ -33,6 +67,19 @@ module Tasku
33
67
 
34
68
  class_option :db, type: :string, desc: "Path to SQLite database (default: ~/.tasku/tasks.db)", hide: true
35
69
 
70
+ desc "config SUBCOMMAND", "Manage user preferences"
71
+ subcommand "config", ConfigApp
72
+
73
+ def help(*args)
74
+ if args.empty?
75
+ puts ""
76
+ puts pastel.bold(LOGO)
77
+ puts " #{pastel.bold(TAGLINE)}"
78
+ puts ""
79
+ end
80
+ super
81
+ end
82
+
36
83
  desc "sql QUERY", "Run a raw SQL query against the database"
37
84
  def sql(query)
38
85
  if query.strip.upcase.match?(/\A(SELECT|PRAGMA|EXPLAIN)/)
@@ -157,13 +204,14 @@ module Tasku
157
204
  dataset = dataset.order(order)
158
205
 
159
206
  tasks = dataset.all
160
- terminal.render_list(tasks)
207
+ bar = { "bar_project" => Config.get("bar_project"), "bar_priority" => Config.get("bar_priority"), "bar_status" => Config.get("bar_status") }
208
+ terminal.render_list(tasks, colour_map: Project.colour_map, spacing: Config.get("list_spacing"), bar: bar)
161
209
  end
162
210
 
163
211
  desc "show ID", "Show task details"
164
212
  def show(id)
165
213
  task = find_task(id)
166
- terminal.render_show(task)
214
+ terminal.render_show(task, colour_map: Project.colour_map)
167
215
  end
168
216
 
169
217
  desc "edit ID", "Edit a task"
@@ -241,25 +289,48 @@ module Tasku
241
289
  desc "stats", "Show task statistics"
242
290
  def stats
243
291
  tasks = Task.dataset.all
244
- terminal.render_stats(tasks) if tasks
292
+ terminal.render_stats(tasks, colour_map: Project.colour_map) if tasks
245
293
  end
246
294
 
247
295
  desc "projects", "List all projects"
248
296
  def projects
249
- projects = Task.dataset.select(:project).where(Sequel.~(project: nil)).distinct.order(:project).map(:project)
250
- if projects.empty?
297
+ project_names = Task.dataset.select(:project).where(Sequel.~(project: nil)).distinct.order(:project).map(:project)
298
+ if project_names.empty?
251
299
  puts pastel.yellow(" No projects found.")
252
300
  return
253
301
  end
254
302
 
303
+ colour_map = Project.colour_map
255
304
  puts ""
256
- projects.each do |p|
305
+ project_names.each do |p|
257
306
  count = Task.where(project: p).count
258
- puts " #{pastel.bold(p)} #{pastel.dim("(#{count} task(s))")}"
307
+ colour = colour_map[p]
308
+ swatch = colour ? "#{hex_colour_str("█", colour)} " : " "
309
+ name_str = colour ? hex_bold_str(p, colour) : pastel.bold(p)
310
+ puts " #{swatch}#{name_str} #{pastel.dim("(#{count} task(s))")}"
259
311
  end
260
312
  puts ""
261
313
  end
262
314
 
315
+ desc "colour PROJECT HEX", "Set a project's display colour (e.g. #FF5733). Omit HEX to clear."
316
+ def colour(project, hex = nil)
317
+ if hex.nil?
318
+ proj = Project[project]
319
+ if proj
320
+ proj.update(colour: nil)
321
+ puts pastel.green(" Colour cleared for project '#{project}'.")
322
+ else
323
+ puts pastel.yellow(" No colour set for project '#{project}'.")
324
+ end
325
+ return
326
+ end
327
+
328
+ validated = validate_hex!(hex)
329
+ Project.find_or_create(name: project).update(colour: validated)
330
+ swatch = hex_colour_str("█", validated)
331
+ puts " #{swatch} Colour #{pastel.bold(validated)} set for project '#{pastel.bold(project)}'."
332
+ end
333
+
263
334
  desc "categories", "List all categories"
264
335
  def categories
265
336
  categories = Task.dataset.select(:category).where(Sequel.~(category: nil)).distinct.order(:category).map(:category)
@@ -428,6 +499,24 @@ module Tasku
428
499
  "archived" => :dim
429
500
  }.freeze
430
501
 
502
+ def validate_hex!(hex)
503
+ hex = hex.start_with?("#") ? hex : "##{hex}"
504
+ unless hex.match?(/\A#[0-9a-fA-F]{6}\z/)
505
+ abort pastel.red("Invalid hex colour '#{hex}'. Use format #RRGGBB.")
506
+ end
507
+ hex
508
+ end
509
+
510
+ def hex_colour_str(text, hex)
511
+ r, g, b = hex.delete("#").scan(/../).map { |c| c.to_i(16) }
512
+ "\e[38;2;#{r};#{g};#{b}m#{text}\e[0m"
513
+ end
514
+
515
+ def hex_bold_str(text, hex)
516
+ r, g, b = hex.delete("#").scan(/../).map { |c| c.to_i(16) }
517
+ "\e[1;38;2;#{r};#{g};#{b}m#{text}\e[0m"
518
+ end
519
+
431
520
  def colour_cell(col, padded_val, row)
432
521
  case col.to_s
433
522
  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: "↓" },
@@ -29,13 +46,13 @@ module Tasku
29
46
  @term_width = [terminal_width, 80].min
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.0"
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.0
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-07-24 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