Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,12 @@ assets. Install it with `git config core.hooksPath .githooks`.
- Don't hardcode field-specific semantics. Effort, priority coefficients,
UDA interpretation must go through configurable mappers. See
`DEFAULT_URGENCY_VALUE_MAPPERS` in `lua/taskwarrior/taskmd.lua` for the pattern.
- When shelling out, always include `rc.bulk=0 rc.confirmation=off` in the
Taskwarrior invocation. Interactive prompts break headless usage.
- Route plugin-owned Taskwarrior processes through
`require("taskwarrior.command")`. Use `read()` for queries, `mutate()` for
state changes, and `start()` for asynchronous work. Do not call
`vim.fn.system*` or `vim.fn.jobstart` with `task` directly: the command
boundary owns availability checks, non-interactive rc flags, argv safety,
and exit-result handling. The isolated tutor database is the sole exception.
- Sanitize `\n` out of any string before `nvim_buf_set_lines` — vim treats
those as a buffer-corruption error.
- Never trust `vim.cmd("normal!")` in headless tests — it silently no-ops
Expand Down
41 changes: 26 additions & 15 deletions lua/taskwarrior/apply.lua
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
local M = {}
local command = require("taskwarrior.command")

-- Backup the Taskwarrior data directory before applying changes. Best-effort:
-- failures are reported but do not block the apply.
local function backup_taskdata()
local config = require("taskwarrior.config")
if not config.options.auto_backup then return end
local ok, taskdata_raw = pcall(vim.fn.system, "task _get rc.data.location 2>/dev/null")
if not ok then return end
local taskdata = tostring(taskdata_raw or ""):gsub("%s+$", "")
local location = command.read({ "_get", "rc.data.location" })
if not location.ok then return end
local taskdata = tostring(location.output or ""):gsub("%s+$", "")
if taskdata == "" or vim.fn.isdirectory(taskdata) ~= 1 then return end
local data = vim.fn.stdpath("data")
local dest_root = data .. "/taskwarrior.nvim/backups"
Expand All @@ -20,10 +21,8 @@ local function backup_taskdata()
vim.fn.mkdir(dest_root, "p")
local stamp = os.date("%Y-%m-%d-%H%M%S")
local dest = dest_root .. "/" .. stamp
local copy_ok, copy_err = pcall(function()
vim.fn.system(string.format("cp -a %s %s",
vim.fn.shellescape(taskdata), vim.fn.shellescape(dest)))
end)
local copy_ok, copy_err = pcall(vim.fn.system, { "cp", "-a", taskdata, dest })
copy_ok = copy_ok and vim.v.shell_error == 0
if not copy_ok then
vim.notify("taskwarrior.nvim: auto-backup failed (" .. tostring(copy_err) .. ")",
vim.log.levels.WARN)
Expand Down Expand Up @@ -268,18 +267,28 @@ function M.undo(bufnr, refresh_fn)
prompt = string.format("Undo %d action(s) from last save?", count),
}, function(choice)
if choice ~= "Undo" then return end
local failed = 0
local succeeded = 0
local failure_output
for _ = 1, count do
vim.fn.system({ "task", "rc.bulk=0", "rc.confirmation=off", "undo" })
if vim.v.shell_error ~= 0 then failed = failed + 1 end
local result = command.mutate({ "undo" })
if not result.ok then
failure_output = result.output
break
end
succeeded = succeeded + 1
end
vim.b[bufnr].task_last_action_count = nil
if failed > 0 then
vim.notify(string.format("taskwarrior.nvim: undo completed (%d failed)", failed), vim.log.levels.WARN)
local remaining = count - succeeded
vim.b[bufnr].task_last_action_count = remaining > 0 and remaining or nil
if remaining > 0 then
local msg = string.format(
"taskwarrior.nvim: undid %d action(s); %d still pending",
succeeded, remaining)
if failure_output and failure_output ~= "" then msg = msg .. "\n" .. failure_output end
vim.notify(msg, vim.log.levels.ERROR)
else
vim.notify(string.format("taskwarrior.nvim: undid %d action(s)", count))
end
refresh_fn(bufnr)
if succeeded > 0 then refresh_fn(bufnr) end
end)
end

Expand Down Expand Up @@ -325,7 +334,9 @@ function M.do_apply_and_refresh(bufnr, tmpfile, on_delete, refresh_fn, opts)
end
if summary.errors and #summary.errors > 0 then
msg = msg .. string.format(" (%d errors!)", #summary.errors)
vim.notify(msg, vim.log.levels.WARN)
local first = summary.errors[1] and summary.errors[1].error
if first and first ~= "" then msg = msg .. "\n" .. first end
vim.notify(msg, vim.log.levels.ERROR)
elseif (summary.action_count or 0) > 0 then
vim.notify(msg)
end
Expand Down
37 changes: 16 additions & 21 deletions lua/taskwarrior/buffer.lua
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
local M = {}
local command = require("taskwarrior.command")

-- ---------------------------------------------------------------------------
-- Shared utilities
-- ---------------------------------------------------------------------------

local function run(cmd)
local out = vim.fn.system(cmd)
local ok = vim.v.shell_error == 0
return out, ok
end

local function uuid_from_line(line)
return line:match("<!%-%-.*uuid:([0-9a-fA-F]+).*%-%->")
end
Expand Down Expand Up @@ -913,11 +908,8 @@ function M.setup_buf_keymaps(bufnr)
end
vim.ui.input({ prompt = "Annotation: " }, function(text)
if not text or text == "" then return end
local _, ok = run(
string.format("task rc.bulk=0 rc.confirmation=off %s annotate %s",
short_uuid, vim.fn.shellescape(text))
)
if ok then
local result = command.mutate({ short_uuid, "annotate", text })
if result.ok then
vim.notify("taskwarrior.nvim: annotation added")
M.refresh_buf(bufnr)
else
Expand All @@ -939,12 +931,16 @@ function M.setup_buf_keymaps(bufnr)
completion = "custom,v:lua.require'taskwarrior'._complete_modify",
}, function(input)
if not input or input == "" then return end
local escaped = input:gsub("'", "'\\''")
local _, ok = run(
string.format("task rc.bulk=0 rc.confirmation=off %s modify '%s'",
short_uuid, escaped)
)
if ok then
local parts, err = command.parse_args(input)
if not parts then
vim.notify("taskwarrior.nvim: invalid modify arguments\n" .. err,
vim.log.levels.ERROR)
return
end
local args = { short_uuid, "modify" }
vim.list_extend(args, parts)
local result = command.mutate(args)
if result.ok then
vim.notify("taskwarrior.nvim: modified")
M.refresh_buf(bufnr)
else
Expand Down Expand Up @@ -1021,10 +1017,9 @@ function M.setup_buf_keymaps(bufnr)
vim.notify("taskwarrior.nvim: no UUID on this line", vim.log.levels.WARN)
return
end
local out, ok = run(
string.format("task rc.bulk=0 rc.confirmation=off %s info", short_uuid)
)
if not ok or out == "" then
local result = command.read({ short_uuid, "info" })
local out = result.output
if not result.ok or out == "" then
vim.notify("taskwarrior.nvim: info failed", vim.log.levels.ERROR)
return
end
Expand Down
24 changes: 15 additions & 9 deletions lua/taskwarrior/bulk.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,12 @@
-- entirely (rc.bulk=0 + one invocation per task).

local M = {}
local command = require("taskwarrior.command")

local function uuid_from_line(line)
return line:match("<!%-%-.*uuid:([0-9a-fA-F]+).*%-%->")
end

local function run(cmd)
local out = vim.fn.system(cmd)
return out, vim.v.shell_error == 0
end

-- range: { line1, line2 } (1-based, inclusive)
-- spec: the modify spec, e.g. "+triage project:inbox"
function M.modify(range, spec)
Expand All @@ -40,15 +36,25 @@ function M.modify(range, spec)
end

local failed = 0
local parts, parse_err = command.parse_args(spec)
if not parts then
require("taskwarrior.notify")("error",
"taskwarrior.nvim: invalid modify arguments\n" .. parse_err,
vim.log.levels.ERROR)
return
end
for _, u in ipairs(uuids) do
local _, ok = run(string.format(
"task rc.bulk=0 rc.confirmation=off %s modify %s", u, spec))
if not ok then failed = failed + 1 end
local args = { u, "modify" }
vim.list_extend(args, parts)
if not command.mutate(args).ok then failed = failed + 1 end
end
local msg = string.format("taskwarrior.nvim: modified %d task%s",
#uuids - failed, (#uuids - failed) ~= 1 and "s" or "")
if failed > 0 then msg = msg .. " (" .. failed .. " failed)" end
require("taskwarrior.notify")("modify", msg)
require("taskwarrior.notify")(
failed > 0 and "error" or "modify",
msg,
failed > 0 and vim.log.levels.ERROR or nil)

if vim.b[bufnr].task_filter ~= nil then
pcall(function() require("taskwarrior.buffer").refresh_buf(bufnr) end)
Expand Down
14 changes: 4 additions & 10 deletions lua/taskwarrior/capture.lua
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
local M = {}

local function run(cmd)
local out = vim.fn.system(cmd)
local ok = vim.v.shell_error == 0
return out, ok
end
local command = require("taskwarrior.command")

-- Omnifunc for the capture window — delegates to task.completion.complete_filter
-- so users get project:, +tag, priority:, field: completions with <Tab>.
Expand Down Expand Up @@ -121,17 +116,16 @@ function M.open(refresh_fn)
else
vim.notify("taskwarrior.nvim: add failed", vim.log.levels.ERROR)
end
refresh_fn()
if add_ok then refresh_fn() end
return
end
end

-- Fallback only when parse_capture itself failed (taskmd module missing,
-- or input was unparseable). Use literal add so the user doesn't lose
-- their typed content.
local escaped = line:gsub("'", "'\\''")
local _, ok = run("task rc.bulk=0 rc.confirmation=off add -- '" .. escaped .. "'")
if ok then
local result = command.mutate({ "add", "--", line })
if result.ok then
vim.notify("taskwarrior.nvim: added task (unparsed)")
refresh_fn()
else
Expand Down
15 changes: 8 additions & 7 deletions lua/taskwarrior/cmp.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
-- - bare word → completes field names (project:, priority:, due:, etc.)

local source = {}
local command = require("taskwarrior.command")

local KNOWN_FIELDS = {
"project:", "priority:", "due:", "scheduled:", "recur:",
Expand All @@ -23,19 +24,19 @@ local function refresh_cache()
local now = vim.loop.now() / 1000
if _cache.mtime + 10 > now and _cache.projects then return end
_cache.mtime = now
local function lines(cmd)
local out = vim.fn.systemlist(cmd)
if vim.v.shell_error ~= 0 then return {} end
local function lines(subcommand)
local result = command.read({ subcommand })
if not result.ok then return {} end
local r = {}
for _, l in ipairs(out) do
for l in result.output:gmatch("[^\r\n]+") do
l = l:gsub("%s+$", "")
if l ~= "" then table.insert(r, l) end
end
return r
end
_cache.projects = lines("task _projects 2>/dev/null")
_cache.tags = lines("task _tags 2>/dev/null")
_cache.udas = lines("task _udas 2>/dev/null")
_cache.projects = lines("_projects")
_cache.tags = lines("_tags")
_cache.udas = lines("_udas")
end

function source.new()
Expand Down
Loading
Loading