tasku 0.3.9 → 0.4.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 +4 -4
- data/CHANGELOG.md +97 -0
- data/lib/tasku/cli.rb +369 -11
- data/lib/tasku/config.rb +103 -0
- data/lib/tasku/database.rb +12 -0
- data/lib/tasku/output/terminal.rb +162 -31
- data/lib/tasku/task.rb +6 -0
- data/lib/tasku/tui/mado_list.rb +130 -17
- data/lib/tasku/tui/mado_log.rb +18 -0
- data/lib/tasku/version.rb +1 -1
- data/lib/tasku.rb +6 -0
- metadata +10 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 206dc71e9498feb028e4c8b05fc3de8a23d6073b1da9db0988ef0acea3fc5f5d
|
|
4
|
+
data.tar.gz: 58dacb2562825ff61794649652c617b3ea7a4a619804a32c56067ff5ef2fd1ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 719c9a295c5868d5dbeb152e35cacde49d5be9f2ad229e2818a20d3eeaecf6ae86c461ea27e2d921c5b15ecbb48f0180f3e4c4ddc56d020dd36baaf5d901fb3e
|
|
7
|
+
data.tar.gz: 3c09b8f6970813d4a73d0772c2a92530d9e5958ad22f9fdd52c77a4f6fdc0a1b132af902ee59ba75060b25227695b48f7b22cf30541990cc763a4de41d5216c0
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to Tasku are documented here.
|
|
4
|
+
|
|
5
|
+
## [0.4.1] - 2026-09-29
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Cloud sync: project colours now sync down** — `tasku cloud sync` applied only the tasks from the server's response and ignored its `projects`, so colours set on another machine never arrived. They are now created or updated locally, and the sync summary reports how many changed. In Mado this also restores project colours and workspaces on a freshly synced machine
|
|
9
|
+
|
|
10
|
+
## [0.4.0] - 2026-09-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Tasku Cloud sync** — two-way sync with `tasku cloud sync`; authenticate with `tasku cloud login <token>`, check status with `tasku cloud status`, and revoke with `tasku cloud logout`
|
|
14
|
+
- **`tasku config open`** — opens the config file in `$EDITOR` pre-seeded with all available keys and their defaults
|
|
15
|
+
- **Numeric range preferences** — config keys that accept a numeric range (e.g. column minimum widths) now show their valid range in `tasku config list`
|
|
16
|
+
- **`--code` flag** on `add` and `edit` — attach a short code to a task (e.g. `MDO-42`)
|
|
17
|
+
- **`tasku sql`** — run a raw SQL `SELECT` query against the local database
|
|
18
|
+
- **Category filtering** — `tasku list --category` and category column in list output
|
|
19
|
+
- **Column visibility preferences** — `col_project`, `col_category`, `col_priority`, `col_status`, `col_due` can each be toggled `on`/`off` via `tasku config set`
|
|
20
|
+
- **Column minimum width preferences** — `col_name_min`, `col_priority_min`, `col_status_min`, `col_due_min` for fine-tuning layout
|
|
21
|
+
- **`tasku config open`** — open the config JSON in `$EDITOR` (pre-seeded with defaults)
|
|
22
|
+
- **UUID field** on tasks for stable cross-device identity during cloud sync
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **Mado: status filter preserved after add/edit/delete** — `MadoList` now receives the full `list_cmd` (including `--status`, `--today`, `--overdue`, etc.) and passes it to `exec` on return, so the active view is restored correctly after an interactive command
|
|
26
|
+
- **Mado: project filter preserved after add/edit/delete** — the inline-exec protocol (`\x02`) now replaces the MadoList process via `exec` instead of nesting it in a `system()` call, so the new MadoList inherits the correct `@list_cmd` and filter is preserved when navigating back from an interactive command
|
|
27
|
+
- **Mado: project filter lost on workspace switch** — the open-panel flag is now tracked correctly for sidebar-positioned Tasku (not just the top-bar overlay), so switching workspace reliably refreshes the task list with the new project filter
|
|
28
|
+
- **Mado: terminal left in raw mode after inline exec** — `$stdin.cooked!` is now called before `exec` when handling a `\x02tasku list` command, preventing the shell from inheriting raw-mode terminal attributes
|
|
29
|
+
- **Due date column truncation** — `DUE_COL_MIN_WIDTH` raised to 17 to accommodate the longest possible due cell (`"Mmm DD (NNNd ago)"`)
|
|
30
|
+
- **MadoList row overflow** — row prefix accounting corrected to 6 characters (`" [>] "`), matching the actual rendered prefix; was previously 4, causing rows to exceed terminal width and wrap
|
|
31
|
+
- **Name truncation** — task names that exceed available space are now truncated with `…`; names that fit are never truncated, fixing cases where the due-date column wrapped to the next line
|
|
32
|
+
- **Column auto-drop on narrow terminals** — optional columns (project → category → priority → status) are dropped one at a time until at least 15 characters are available for task names, rather than wrapping or clipping
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
- `tasku list` output uses a consistent `│`-separated column layout with a header row
|
|
36
|
+
- `tasku config set` validates values via `ArgumentError` rather than a plain string comparison, supporting range constraints
|
|
37
|
+
- `tasku stats` includes a "By Project" section sorted by task count
|
|
38
|
+
- Sort defaults to `id` (ascending) when no `--sort` flag is given
|
|
39
|
+
- `id` added as an explicit `--sort` option
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## [0.3.9] - 2026-09-04
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
- **Mado TUI list mode** — `tasku list` launches an interactive cursor-driven list when `MADO=1` is set in the environment, designed for embedding in the Mado terminal multiplexer sidebar
|
|
47
|
+
- Arrow-key navigation, selected task ID written to `TASKU_SEL_FILE` for Mado button integration
|
|
48
|
+
- Inline-exec protocol (`\x02`) for receiving filter and display commands from Mado without shell round-trips
|
|
49
|
+
- `build_mado_rows` and `ansi_ljust` helpers exposed on `Output::Terminal` for Mado's renderer
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## [0.3.1] - 2026-08-29
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
- Terminal width was capped at 80 columns regardless of actual PTY size, severely truncating the name column in wider terminals — now uses the real terminal width
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
- `tasku list` sorts by `id` by default
|
|
60
|
+
- `exit_on_failure?` enabled on the CLI so bad subcommands exit with a non-zero status
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## [0.3.0] - 2026-07-24
|
|
65
|
+
|
|
66
|
+
### Added
|
|
67
|
+
- **Project colours** — `tasku colour PROJECT #HEX` stores a hex colour per project; project names are tinted throughout `list`, `show`, and `stats`
|
|
68
|
+
- **Colour bar** — three-segment bar in `tasku list` showing project, priority, and status at a glance (toggle each segment with `tasku config set bar_project on/off` etc.)
|
|
69
|
+
- **User preferences** — `tasku config list` / `tasku config set KEY VALUE` / `tasku config get KEY`, backed by `~/.tasku/config.json`
|
|
70
|
+
- `list_spacing` preference (`compact` / `spacious`)
|
|
71
|
+
- **"By Project" section** in `tasku stats` sorted by task count
|
|
72
|
+
- Logo restored on `tasku help` and bare `tasku` invocation
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## [0.2.0] - 2026-07-01
|
|
77
|
+
|
|
78
|
+
### Added
|
|
79
|
+
- `tasku projects` — list all projects with task counts and colour swatches
|
|
80
|
+
- `tasku categories` — list all categories
|
|
81
|
+
- `tasku done ID` — shorthand to mark a task done
|
|
82
|
+
- `--overdue`, `--today`, `--tomorrow` filter flags on `tasku list`
|
|
83
|
+
- `--sort` and `--order` flags on `tasku list`
|
|
84
|
+
- `--priority`, `--status`, `--tags` filter flags on `tasku list`
|
|
85
|
+
- `--force` / `-f` flag on `tasku delete` to skip confirmation
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## [0.1.0] - 2026-06-29
|
|
90
|
+
|
|
91
|
+
Initial release.
|
|
92
|
+
|
|
93
|
+
- `tasku add`, `tasku list`, `tasku show`, `tasku edit`, `tasku delete`
|
|
94
|
+
- SQLite-backed storage at `~/.tasku/tasks.db`
|
|
95
|
+
- Task fields: name, description, project, category, priority, status, start date, due date, tags, estimated hours
|
|
96
|
+
- `tasku stats` — task breakdown by priority and status
|
|
97
|
+
- Interactive add/edit with `-i` flag (TTY::Prompt)
|
data/lib/tasku/cli.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
3
8
|
module Tasku
|
|
4
9
|
module CLI
|
|
5
10
|
LOGO = <<~LOGO
|
|
@@ -20,7 +25,11 @@ module Tasku
|
|
|
20
25
|
puts ""
|
|
21
26
|
Tasku::Config::VALID_KEYS.each do |key, meta|
|
|
22
27
|
current = Tasku::Config.get(key)
|
|
23
|
-
|
|
28
|
+
if meta[:range]
|
|
29
|
+
options_str = pastel.bold(pastel.green(current)) + pastel.dim(" (#{meta[:range].min}–#{meta[:range].max})")
|
|
30
|
+
else
|
|
31
|
+
options_str = meta[:values].map { |v| v == current ? pastel.bold(pastel.green(v)) : pastel.dim(v) }.join(", ")
|
|
32
|
+
end
|
|
24
33
|
puts " #{pastel.bold(key.ljust(20))} #{options_str} #{pastel.dim("— #{meta[:description]}")}"
|
|
25
34
|
end
|
|
26
35
|
puts ""
|
|
@@ -31,10 +40,11 @@ module Tasku
|
|
|
31
40
|
pastel = Pastel.new
|
|
32
41
|
meta = Tasku::Config::VALID_KEYS[key]
|
|
33
42
|
abort pastel.red("Unknown preference '#{key}'. Run `tasku config list` to see available keys.") unless meta
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
begin
|
|
44
|
+
Tasku::Config.set(key, value)
|
|
45
|
+
rescue ArgumentError => e
|
|
46
|
+
abort pastel.red(" #{e.message}")
|
|
36
47
|
end
|
|
37
|
-
Tasku::Config.set(key, value)
|
|
38
48
|
puts pastel.green(" ✓ #{key} set to '#{value}'.")
|
|
39
49
|
end
|
|
40
50
|
|
|
@@ -45,6 +55,240 @@ 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
|
+
# Local projects were pushed above, so the server's list already reflects
|
|
265
|
+
# them — adopt it wholesale so colours set on other machines come down.
|
|
266
|
+
projects_pulled = 0
|
|
267
|
+
(body["projects"] || []).each do |sp|
|
|
268
|
+
name = sp["name"].to_s.strip
|
|
269
|
+
colour = sp["colour"].to_s.strip
|
|
270
|
+
next if name.empty? || colour !~ /\A#[0-9A-Fa-f]{6}\z/
|
|
271
|
+
|
|
272
|
+
proj = Tasku::Project.find_or_create(name: name)
|
|
273
|
+
next if proj.colour == colour
|
|
274
|
+
|
|
275
|
+
proj.update(colour: colour)
|
|
276
|
+
projects_pulled += 1
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
Tasku::Config.last_synced_at = synced_at ? Time.parse(synced_at).utc : Time.now.utc
|
|
280
|
+
|
|
281
|
+
puts ""
|
|
282
|
+
puts " #{pastel.green("✓")} Sync complete."
|
|
283
|
+
puts " #{pastel.bold(local_tasks.count.to_s)} #{local_tasks.count == 1 ? "task" : "tasks"} pushed to cloud."
|
|
284
|
+
puts " #{pastel.bold(created.to_s)} #{created == 1 ? "task" : "tasks"} pulled from cloud." if created > 0
|
|
285
|
+
puts " #{pastel.bold(updated.to_s)} #{updated == 1 ? "task" : "tasks"} updated from cloud." if updated > 0
|
|
286
|
+
puts " #{pastel.bold(projects_pulled.to_s)} #{projects_pulled == 1 ? "project colour" : "project colours"} updated from cloud." if projects_pulled > 0
|
|
287
|
+
if conflicts > 0
|
|
288
|
+
puts " #{pastel.yellow("⚠")} #{conflicts} #{conflicts == 1 ? "conflict" : "conflicts"} — visit #{pastel.bold(url)} to resolve."
|
|
289
|
+
end
|
|
290
|
+
puts ""
|
|
291
|
+
end
|
|
48
292
|
end
|
|
49
293
|
|
|
50
294
|
class App < Thor
|
|
@@ -74,6 +318,9 @@ module Tasku
|
|
|
74
318
|
desc "config SUBCOMMAND", "Manage user preferences"
|
|
75
319
|
subcommand "config", ConfigApp
|
|
76
320
|
|
|
321
|
+
desc "cloud SUBCOMMAND", "Sync tasks with Tasku Cloud"
|
|
322
|
+
subcommand "cloud", CloudApp
|
|
323
|
+
|
|
77
324
|
def help(*args)
|
|
78
325
|
if args.empty?
|
|
79
326
|
puts ""
|
|
@@ -161,6 +408,11 @@ module Tasku
|
|
|
161
408
|
option_add
|
|
162
409
|
end
|
|
163
410
|
|
|
411
|
+
if attrs.nil?
|
|
412
|
+
puts pastel.yellow(" Add cancelled.")
|
|
413
|
+
return
|
|
414
|
+
end
|
|
415
|
+
|
|
164
416
|
task = Task.create(attrs)
|
|
165
417
|
terminal.render_added(task)
|
|
166
418
|
rescue Sequel::ValidationFailed => e
|
|
@@ -173,9 +425,11 @@ module Tasku
|
|
|
173
425
|
option :project, type: :string, desc: "Filter by project"
|
|
174
426
|
option :category, type: :string, desc: "Filter by category"
|
|
175
427
|
option :tags, type: :string, desc: "Filter by tag (comma-separated)"
|
|
176
|
-
option :overdue,
|
|
177
|
-
option :
|
|
178
|
-
option :
|
|
428
|
+
option :overdue, type: :boolean, desc: "Show only overdue tasks"
|
|
429
|
+
option :today, type: :boolean, desc: "Show tasks due today"
|
|
430
|
+
option :tomorrow, type: :boolean, desc: "Show tasks due tomorrow"
|
|
431
|
+
option :sort, type: :string, desc: "Sort by: id, name, priority, due, status, created"
|
|
432
|
+
option :order, type: :string, desc: "Order: asc, desc", default: "asc"
|
|
179
433
|
def list
|
|
180
434
|
dataset = Task.dataset
|
|
181
435
|
|
|
@@ -196,6 +450,16 @@ module Tasku
|
|
|
196
450
|
dataset = dataset.where { due_day < today }.exclude(status: %w[done cancelled])
|
|
197
451
|
end
|
|
198
452
|
|
|
453
|
+
if options[:today]
|
|
454
|
+
today = Date.today
|
|
455
|
+
dataset = dataset.where(due_day: today).exclude(status: %w[done cancelled])
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
if options[:tomorrow]
|
|
459
|
+
tomorrow = Date.today + 1
|
|
460
|
+
dataset = dataset.where(due_day: tomorrow).exclude(status: %w[done cancelled])
|
|
461
|
+
end
|
|
462
|
+
|
|
199
463
|
sort_col = case options[:sort]
|
|
200
464
|
when "name" then :name
|
|
201
465
|
when "priority" then Sequel.case(Task::VALID_PRIORITIES.each_with_index.to_h, 999, :priority)
|
|
@@ -210,10 +474,29 @@ module Tasku
|
|
|
210
474
|
|
|
211
475
|
tasks = dataset.all
|
|
212
476
|
bar = { "bar_project" => Config.get("bar_project"), "bar_priority" => Config.get("bar_priority"), "bar_status" => Config.get("bar_status") }
|
|
477
|
+
cols_cfg = {
|
|
478
|
+
"col_project" => Config.get("col_project"),
|
|
479
|
+
"col_category" => Config.get("col_category"),
|
|
480
|
+
"col_priority" => Config.get("col_priority"),
|
|
481
|
+
"col_status" => Config.get("col_status"),
|
|
482
|
+
"col_due" => Config.get("col_due"),
|
|
483
|
+
"col_name_min" => Config.get("col_name_min"),
|
|
484
|
+
"col_priority_min" => Config.get("col_priority_min"),
|
|
485
|
+
"col_status_min" => Config.get("col_status_min"),
|
|
486
|
+
"col_due_min" => Config.get("col_due_min")
|
|
487
|
+
}
|
|
213
488
|
if ENV["MADO"] == "1"
|
|
214
|
-
|
|
489
|
+
list_cmd = "tasku list"
|
|
490
|
+
list_cmd += " --status #{Shellwords.shellescape(options[:status])}" if options[:status]
|
|
491
|
+
list_cmd += " --priority #{Shellwords.shellescape(options[:priority])}" if options[:priority]
|
|
492
|
+
list_cmd += " --project #{Shellwords.shellescape(options[:project])}" if options[:project]
|
|
493
|
+
list_cmd += " --category #{Shellwords.shellescape(options[:category])}" if options[:category]
|
|
494
|
+
list_cmd += " --today" if options[:today]
|
|
495
|
+
list_cmd += " --tomorrow" if options[:tomorrow]
|
|
496
|
+
list_cmd += " --overdue" if options[:overdue]
|
|
497
|
+
Tasku::TUI::MadoList.new(tasks, colour_map: Project.colour_map, bar: bar, cols_cfg: cols_cfg, project: options[:project], list_cmd: list_cmd).run
|
|
215
498
|
else
|
|
216
|
-
terminal.render_list(tasks, colour_map: Project.colour_map, spacing: Config.get("list_spacing"), bar: bar)
|
|
499
|
+
terminal.render_list(tasks, colour_map: Project.colour_map, spacing: Config.get("list_spacing"), bar: bar, cols_cfg: cols_cfg)
|
|
217
500
|
end
|
|
218
501
|
end
|
|
219
502
|
|
|
@@ -237,10 +520,19 @@ module Tasku
|
|
|
237
520
|
option :hours, type: :numeric, desc: "Estimated hours"
|
|
238
521
|
option :code, type: :string, desc: "Code"
|
|
239
522
|
option :clear, type: :string, desc: "Clear a field: description, start, due, model, tags, hours, code"
|
|
523
|
+
option :interactive, type: :boolean, aliases: "-i", desc: "Interactive mode", default: false
|
|
240
524
|
def edit(id)
|
|
241
525
|
task = find_task(id)
|
|
242
526
|
|
|
243
|
-
attrs =
|
|
527
|
+
attrs = if options[:interactive]
|
|
528
|
+
interactive_edit(task)
|
|
529
|
+
else
|
|
530
|
+
option_edit
|
|
531
|
+
end
|
|
532
|
+
if attrs.nil?
|
|
533
|
+
puts pastel.yellow(" Edit cancelled.")
|
|
534
|
+
return
|
|
535
|
+
end
|
|
244
536
|
if attrs.empty? && !options[:clear]
|
|
245
537
|
puts pastel.yellow("No changes specified. Use --help to see available options.")
|
|
246
538
|
return
|
|
@@ -296,8 +588,11 @@ module Tasku
|
|
|
296
588
|
end
|
|
297
589
|
|
|
298
590
|
desc "stats", "Show task statistics"
|
|
591
|
+
option :project, type: :string, desc: "Filter by project"
|
|
299
592
|
def stats
|
|
300
|
-
|
|
593
|
+
dataset = Task.dataset
|
|
594
|
+
dataset = dataset.where(project: options[:project]) if options[:project]
|
|
595
|
+
tasks = dataset.all
|
|
301
596
|
terminal.render_stats(tasks, colour_map: Project.colour_map) if tasks
|
|
302
597
|
end
|
|
303
598
|
|
|
@@ -437,6 +732,69 @@ module Tasku
|
|
|
437
732
|
tags: (tags unless tags&.empty?),
|
|
438
733
|
estimated_hours: hours
|
|
439
734
|
}.compact
|
|
735
|
+
rescue TTY::Reader::InputInterrupt
|
|
736
|
+
nil
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def interactive_edit(task)
|
|
740
|
+
prompt = TTY::Prompt.new
|
|
741
|
+
|
|
742
|
+
name = prompt.ask("Task name:", default: task.name, required: true) do |q|
|
|
743
|
+
q.modify :strip
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
description = prompt.ask("Description:", default: task.description || "")
|
|
747
|
+
description = nil if description&.empty?
|
|
748
|
+
|
|
749
|
+
project = prompt.ask("Project:", default: task.project || "")
|
|
750
|
+
project = nil if project&.empty?
|
|
751
|
+
|
|
752
|
+
category = prompt.ask("Category:", default: task.category || "")
|
|
753
|
+
category = nil if category&.empty?
|
|
754
|
+
|
|
755
|
+
priority = prompt.select("Priority?", %w[none low medium high urgent],
|
|
756
|
+
default: task.priority || "none")
|
|
757
|
+
status = prompt.select("Status?", Task::VALID_STATUSES,
|
|
758
|
+
default: task.status || "todo")
|
|
759
|
+
|
|
760
|
+
start_day = prompt.ask("Start date (YYYY-MM-DD, optional):",
|
|
761
|
+
default: task.start_day&.to_s || "")
|
|
762
|
+
start_day = nil if start_day&.empty?
|
|
763
|
+
|
|
764
|
+
due_day = prompt.ask("Due date (YYYY-MM-DD, optional):",
|
|
765
|
+
default: task.due_day&.to_s || "")
|
|
766
|
+
due_day = nil if due_day&.empty?
|
|
767
|
+
|
|
768
|
+
model_name = prompt.ask("Model:", default: task.model_name || "")
|
|
769
|
+
model_name = nil if model_name&.empty?
|
|
770
|
+
|
|
771
|
+
code = prompt.ask("Code:", default: task.code || "")
|
|
772
|
+
code = nil if code&.empty?
|
|
773
|
+
|
|
774
|
+
tags = prompt.ask("Tags (comma-separated):", default: task.tags || "")
|
|
775
|
+
tags = nil if tags&.empty?
|
|
776
|
+
|
|
777
|
+
hours = prompt.ask("Estimated hours:", default: task.estimated_hours&.to_s || "") do |q|
|
|
778
|
+
q.convert(:float, "")
|
|
779
|
+
end
|
|
780
|
+
hours = nil if hours.is_a?(String) && hours.empty?
|
|
781
|
+
|
|
782
|
+
{
|
|
783
|
+
name: name,
|
|
784
|
+
description: description,
|
|
785
|
+
project: project,
|
|
786
|
+
category: category,
|
|
787
|
+
start_day: parse_date(start_day),
|
|
788
|
+
due_day: parse_date(due_day),
|
|
789
|
+
model_name: model_name,
|
|
790
|
+
code: code,
|
|
791
|
+
priority: priority,
|
|
792
|
+
status: status,
|
|
793
|
+
tags: tags,
|
|
794
|
+
estimated_hours: hours
|
|
795
|
+
}.compact
|
|
796
|
+
rescue TTY::Reader::InputInterrupt
|
|
797
|
+
nil
|
|
440
798
|
end
|
|
441
799
|
|
|
442
800
|
def option_add
|