* fix(diff): preserve due/rec when absent from buffer line Problem: `diff.apply` overwrites `task.due` and `task.recur` with `nil` whenever those fields aren't present as inline tokens in the buffer line. Because metadata is rendered as virtual text (never in the line text), every description edit silently clears due dates and recurrence rules. Solution: Only update `due`, `recur`, and `recur_mode` in the existing- task branch when the parsed entry actually contains them (non-nil). Users can still set/change these inline by typing `due:<date>` or `rec:<rule>`; clearing them requires `:Pending edit <id> -due`. * refactor: remove project-local store discovery Problem: `store.resolve_path()` searched upward for `.pending.json`, silently splitting task data across multiple files depending on CWD. Solution: `resolve_path()` now always returns `config.get().data_path`. Remove `M.init()` and the `:Pending init` command and tab-completion entry. Remove the project-local health message. * refactor: extract log.lua, standardise [pending.nvim]: prefix Problem: Notifications were scattered across files using bare `vim.notify` with inconsistent `pending.nvim: ` prefixes, and the `debug` guard in `textobj.lua` and `init.lua` was duplicated inline. Solution: Add `lua/pending/log.lua` with `info`, `warn`, `error`, and `debug` functions (prefix `[pending.nvim]: `). `log.debug` only fires when `config.debug = true` or the optional `override` param is `true`. Replace all `vim.notify` callsites and remove inline debug guards. * feat(parse): configurable input date formats Problem: `due:` only accepted ISO `YYYY-MM-DD` and built-in keywords; users expecting locale-style dates like `03/15/2026` or `15-Mar-2026` had no way to configure alternative input formats. Solution: Add `input_date_formats` config field (string[]). Each entry is a strftime-like format string supporting `%Y`, `%y`, `%m`, `%d`, `%e`, `%b`, `%B`. Formats are tried in order after built-in keywords fail. When no year specifier is present the current or next year is inferred. Update vimdoc and add 8 parse_spec tests.
383 lines
8.4 KiB
Lua
383 lines
8.4 KiB
Lua
local buffer = require('pending.buffer')
|
|
local config = require('pending.config')
|
|
local log = require('pending.log')
|
|
|
|
---@class pending.textobj
|
|
local M = {}
|
|
|
|
---@param ... any
|
|
---@return nil
|
|
local function dbg(...)
|
|
log.debug(string.format(...))
|
|
end
|
|
|
|
---@param lnum integer
|
|
---@param meta pending.LineMeta[]
|
|
---@return string
|
|
local function get_line_from_buf(lnum, meta)
|
|
local _ = meta
|
|
local bufnr = buffer.bufnr()
|
|
if not bufnr then
|
|
return ''
|
|
end
|
|
local lines = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, false)
|
|
return lines[1] or ''
|
|
end
|
|
|
|
---@param line string
|
|
---@return integer start_col
|
|
---@return integer end_col
|
|
function M.inner_task_range(line)
|
|
local prefix_end = line:find('/') and select(2, line:find('^/%d+/%- %[.%] '))
|
|
if not prefix_end then
|
|
prefix_end = select(2, line:find('^%- %[.%] ')) or 0
|
|
end
|
|
local start_col = prefix_end + 1
|
|
|
|
local dk = config.get().date_syntax or 'due'
|
|
local rk = config.get().recur_syntax or 'rec'
|
|
local dk_pat = '^' .. vim.pesc(dk) .. ':%S+$'
|
|
local rk_pat = '^' .. vim.pesc(rk) .. ':%S+$'
|
|
|
|
local rest = line:sub(start_col)
|
|
local words = {}
|
|
for word in rest:gmatch('%S+') do
|
|
table.insert(words, word)
|
|
end
|
|
|
|
local i = #words
|
|
while i >= 1 do
|
|
local word = words[i]
|
|
if word:match(dk_pat) or word:match('^cat:%S+$') or word:match(rk_pat) then
|
|
i = i - 1
|
|
else
|
|
break
|
|
end
|
|
end
|
|
|
|
if i < 1 then
|
|
return start_col, start_col
|
|
end
|
|
|
|
local desc = table.concat(words, ' ', 1, i)
|
|
local end_col = start_col + #desc - 1
|
|
return start_col, end_col
|
|
end
|
|
|
|
---@param row integer
|
|
---@param meta pending.LineMeta[]
|
|
---@return integer? header_row
|
|
---@return integer? last_row
|
|
function M.category_bounds(row, meta)
|
|
if not meta or #meta == 0 then
|
|
return nil, nil
|
|
end
|
|
|
|
local header_row = nil
|
|
local m = meta[row]
|
|
if not m then
|
|
return nil, nil
|
|
end
|
|
|
|
if m.type == 'header' then
|
|
header_row = row
|
|
else
|
|
for r = row, 1, -1 do
|
|
if meta[r] and meta[r].type == 'header' then
|
|
header_row = r
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
if not header_row then
|
|
return nil, nil
|
|
end
|
|
|
|
local last_row = header_row
|
|
local total = #meta
|
|
for r = header_row + 1, total do
|
|
if meta[r].type == 'header' then
|
|
break
|
|
end
|
|
last_row = r
|
|
end
|
|
|
|
return header_row, last_row
|
|
end
|
|
|
|
---@param count integer
|
|
---@return nil
|
|
function M.a_task(count)
|
|
local meta = buffer.meta()
|
|
if not meta or #meta == 0 then
|
|
return
|
|
end
|
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
|
local m = meta[row]
|
|
if not m or m.type ~= 'task' then
|
|
return
|
|
end
|
|
|
|
local start_row = row
|
|
local end_row = row
|
|
count = math.max(1, count)
|
|
for _ = 2, count do
|
|
local next_row = end_row + 1
|
|
if next_row > #meta then
|
|
break
|
|
end
|
|
if meta[next_row] and meta[next_row].type == 'task' then
|
|
end_row = next_row
|
|
else
|
|
break
|
|
end
|
|
end
|
|
|
|
vim.cmd('normal! ' .. start_row .. 'GV' .. end_row .. 'G')
|
|
end
|
|
|
|
---@param count integer
|
|
---@return nil
|
|
function M.a_task_visual(count)
|
|
vim.cmd('normal! \27')
|
|
M.a_task(count)
|
|
end
|
|
|
|
---@param count integer
|
|
---@return nil
|
|
function M.i_task(count)
|
|
local _ = count
|
|
local meta = buffer.meta()
|
|
if not meta or #meta == 0 then
|
|
return
|
|
end
|
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
|
local m = meta[row]
|
|
if not m or m.type ~= 'task' then
|
|
return
|
|
end
|
|
|
|
local line = get_line_from_buf(row, meta)
|
|
local start_col, end_col = M.inner_task_range(line)
|
|
if start_col > end_col then
|
|
return
|
|
end
|
|
|
|
vim.api.nvim_win_set_cursor(0, { row, start_col - 1 })
|
|
vim.cmd('normal! v')
|
|
vim.api.nvim_win_set_cursor(0, { row, end_col - 1 })
|
|
end
|
|
|
|
---@param count integer
|
|
---@return nil
|
|
function M.i_task_visual(count)
|
|
vim.cmd('normal! \27')
|
|
M.i_task(count)
|
|
end
|
|
|
|
---@param count integer
|
|
---@return nil
|
|
function M.a_category(count)
|
|
local _ = count
|
|
local meta = buffer.meta()
|
|
if not meta or #meta == 0 then
|
|
return
|
|
end
|
|
local view = buffer.current_view_name()
|
|
if view == 'priority' then
|
|
return
|
|
end
|
|
|
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
|
local header_row, last_row = M.category_bounds(row, meta)
|
|
if not header_row or not last_row then
|
|
return
|
|
end
|
|
|
|
local start_row = header_row
|
|
if header_row > 1 and meta[header_row - 1] and meta[header_row - 1].type == 'blank' then
|
|
start_row = header_row - 1
|
|
end
|
|
local end_row = last_row
|
|
if last_row < #meta and meta[last_row + 1] and meta[last_row + 1].type == 'blank' then
|
|
end_row = last_row + 1
|
|
end
|
|
|
|
vim.cmd('normal! ' .. start_row .. 'GV' .. end_row .. 'G')
|
|
end
|
|
|
|
---@param count integer
|
|
---@return nil
|
|
function M.a_category_visual(count)
|
|
vim.cmd('normal! \27')
|
|
M.a_category(count)
|
|
end
|
|
|
|
---@param count integer
|
|
---@return nil
|
|
function M.i_category(count)
|
|
local _ = count
|
|
local meta = buffer.meta()
|
|
if not meta or #meta == 0 then
|
|
return
|
|
end
|
|
local view = buffer.current_view_name()
|
|
if view == 'priority' then
|
|
return
|
|
end
|
|
|
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
|
local header_row, last_row = M.category_bounds(row, meta)
|
|
if not header_row or not last_row then
|
|
return
|
|
end
|
|
|
|
local first_task = nil
|
|
local last_task = nil
|
|
for r = header_row + 1, last_row do
|
|
if meta[r] and meta[r].type == 'task' then
|
|
if not first_task then
|
|
first_task = r
|
|
end
|
|
last_task = r
|
|
end
|
|
end
|
|
|
|
if not first_task or not last_task then
|
|
return
|
|
end
|
|
|
|
vim.cmd('normal! ' .. first_task .. 'GV' .. last_task .. 'G')
|
|
end
|
|
|
|
---@param count integer
|
|
---@return nil
|
|
function M.i_category_visual(count)
|
|
vim.cmd('normal! \27')
|
|
M.i_category(count)
|
|
end
|
|
|
|
---@param count integer
|
|
---@return nil
|
|
function M.next_header(count)
|
|
local meta = buffer.meta()
|
|
if not meta or #meta == 0 then
|
|
return
|
|
end
|
|
local view = buffer.current_view_name()
|
|
if view == 'priority' then
|
|
return
|
|
end
|
|
|
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
|
dbg('next_header: cursor=%d, meta_len=%d, view=%s', row, #meta, view or 'nil')
|
|
local found = 0
|
|
count = math.max(1, count)
|
|
for r = row + 1, #meta do
|
|
if meta[r] and meta[r].type == 'header' then
|
|
found = found + 1
|
|
dbg(
|
|
'next_header: found header at row=%d, cat=%s, found=%d/%d',
|
|
r,
|
|
meta[r].category or '?',
|
|
found,
|
|
count
|
|
)
|
|
if found == count then
|
|
vim.api.nvim_win_set_cursor(0, { r, 0 })
|
|
dbg('next_header: cursor set to row=%d, actual=%d', r, vim.api.nvim_win_get_cursor(0)[1])
|
|
return
|
|
end
|
|
else
|
|
dbg('next_header: row=%d type=%s', r, meta[r] and meta[r].type or 'nil')
|
|
end
|
|
end
|
|
dbg('next_header: no header found after row=%d', row)
|
|
end
|
|
|
|
---@param count integer
|
|
---@return nil
|
|
function M.prev_header(count)
|
|
local meta = buffer.meta()
|
|
if not meta or #meta == 0 then
|
|
return
|
|
end
|
|
local view = buffer.current_view_name()
|
|
if view == 'priority' then
|
|
return
|
|
end
|
|
|
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
|
dbg('prev_header: cursor=%d, meta_len=%d', row, #meta)
|
|
local found = 0
|
|
count = math.max(1, count)
|
|
for r = row - 1, 1, -1 do
|
|
if meta[r] and meta[r].type == 'header' then
|
|
found = found + 1
|
|
dbg(
|
|
'prev_header: found header at row=%d, cat=%s, found=%d/%d',
|
|
r,
|
|
meta[r].category or '?',
|
|
found,
|
|
count
|
|
)
|
|
if found == count then
|
|
vim.api.nvim_win_set_cursor(0, { r, 0 })
|
|
return
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
---@param count integer
|
|
---@return nil
|
|
function M.next_task(count)
|
|
local meta = buffer.meta()
|
|
if not meta or #meta == 0 then
|
|
return
|
|
end
|
|
|
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
|
dbg('next_task: cursor=%d, meta_len=%d', row, #meta)
|
|
local found = 0
|
|
count = math.max(1, count)
|
|
for r = row + 1, #meta do
|
|
if meta[r] and meta[r].type == 'task' then
|
|
found = found + 1
|
|
if found == count then
|
|
dbg('next_task: jumping to row=%d', r)
|
|
vim.api.nvim_win_set_cursor(0, { r, 0 })
|
|
return
|
|
end
|
|
end
|
|
end
|
|
dbg('next_task: no task found after row=%d', row)
|
|
end
|
|
|
|
---@param count integer
|
|
---@return nil
|
|
function M.prev_task(count)
|
|
local meta = buffer.meta()
|
|
if not meta or #meta == 0 then
|
|
return
|
|
end
|
|
|
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
|
dbg('prev_task: cursor=%d, meta_len=%d', row, #meta)
|
|
local found = 0
|
|
count = math.max(1, count)
|
|
for r = row - 1, 1, -1 do
|
|
if meta[r] and meta[r].type == 'task' then
|
|
found = found + 1
|
|
if found == count then
|
|
dbg('prev_task: jumping to row=%d', r)
|
|
vim.api.nvim_win_set_cursor(0, { r, 0 })
|
|
return
|
|
end
|
|
end
|
|
end
|
|
dbg('prev_task: no task found before row=%d', row)
|
|
end
|
|
|
|
return M
|