feat: rename

This commit is contained in:
Barrett Ruth 2026-02-24 15:21:44 -05:00
parent c69a3957c8
commit 78a275d096
Signed by: barrett
GPG key ID: A6C96C9349D2FC81
21 changed files with 191 additions and 191 deletions

194
lua/pending/buffer.lua Normal file
View file

@ -0,0 +1,194 @@
local config = require('pending.config')
local store = require('pending.store')
local views = require('pending.views')
---@class pending.buffer
local M = {}
---@type integer?
local task_bufnr = nil
local task_ns = vim.api.nvim_create_namespace('pending')
---@type 'category'|'priority'|nil
local current_view = nil
---@type pending.LineMeta[]
local _meta = {}
---@return pending.LineMeta[]
function M.meta()
return _meta
end
---@return integer?
function M.bufnr()
return task_bufnr
end
---@return string?
function M.current_view_name()
return current_view
end
---@param bufnr integer
local function set_buf_options(bufnr)
vim.bo[bufnr].buftype = 'acwrite'
vim.bo[bufnr].bufhidden = 'hide'
vim.bo[bufnr].swapfile = false
vim.bo[bufnr].filetype = 'pending'
vim.bo[bufnr].modifiable = true
end
---@param winid integer
local function set_win_options(winid)
vim.wo[winid].conceallevel = 3
vim.wo[winid].concealcursor = 'nvic'
vim.wo[winid].wrap = false
vim.wo[winid].number = false
vim.wo[winid].relativenumber = false
vim.wo[winid].signcolumn = 'no'
vim.wo[winid].foldcolumn = '0'
vim.wo[winid].spell = false
vim.wo[winid].cursorline = true
end
---@param bufnr integer
local function setup_syntax(bufnr)
vim.api.nvim_buf_call(bufnr, function()
vim.cmd([[
syntax clear
syntax match taskId /^\/\d\+\// conceal
syntax match taskHeader /^\S.*$/ contains=taskId
syntax match taskPriority /! / contained containedin=taskLine
syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority
]])
end)
end
---@param bufnr integer
local function setup_indentexpr(bufnr)
vim.bo[bufnr].indentexpr = 'v:lua.require("pending.buffer").get_indent()'
end
---@return integer
function M.get_indent()
local lnum = vim.v.lnum
if lnum <= 1 then
return 0
end
local prev = vim.fn.getline(lnum - 1)
if prev == '' or prev:match('^%S') then
return 0
end
return 2
end
---@param bufnr integer
---@param line_meta pending.LineMeta[]
local function apply_extmarks(bufnr, line_meta)
vim.api.nvim_buf_clear_namespace(bufnr, task_ns, 0, -1)
for i, m in ipairs(line_meta) do
local row = i - 1
if m.type == 'task' then
if m.due then
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
virt_text = { { m.due, 'PendingDue' } },
virt_text_pos = 'right_align',
})
end
if m.status == 'done' then
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) + 2 or 0
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, col_start, {
end_col = #line,
hl_group = 'PendingDone',
})
end
elseif m.type == 'header' then
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
end_col = #line,
hl_group = 'PendingHeader',
})
end
end
end
local function setup_highlights()
local function hl(name, opts)
if vim.fn.hlexists(name) == 0 or vim.tbl_isempty(vim.api.nvim_get_hl(0, { name = name })) then
vim.api.nvim_set_hl(0, name, opts)
end
end
hl('PendingHeader', { bold = true })
hl('PendingDue', { fg = '#888888', italic = true })
hl('PendingDone', { strikethrough = true, fg = '#666666' })
hl('PendingPriority', { fg = '#e06c75', bold = true })
end
---@param bufnr? integer
function M.render(bufnr)
bufnr = bufnr or task_bufnr
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
return
end
current_view = current_view or config.get().default_view
local tasks = store.active_tasks()
local lines, line_meta
if current_view == 'priority' then
lines, line_meta = views.priority_view(tasks)
else
lines, line_meta = views.category_view(tasks)
end
_meta = line_meta
vim.bo[bufnr].modifiable = true
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.bo[bufnr].modified = false
setup_syntax(bufnr)
apply_extmarks(bufnr, line_meta)
end
function M.toggle_view()
if current_view == 'category' then
current_view = 'priority'
else
current_view = 'category'
end
M.render()
end
---@return integer bufnr
function M.open()
setup_highlights()
store.load()
if task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr) then
local wins = vim.fn.win_findbuf(task_bufnr)
if #wins > 0 then
vim.api.nvim_set_current_win(wins[1])
M.render(task_bufnr)
return task_bufnr
end
vim.api.nvim_set_current_buf(task_bufnr)
set_win_options(vim.api.nvim_get_current_win())
M.render(task_bufnr)
return task_bufnr
end
task_bufnr = vim.api.nvim_create_buf(true, false)
vim.api.nvim_buf_set_name(task_bufnr, 'pending://')
set_buf_options(task_bufnr)
setup_indentexpr(task_bufnr)
vim.api.nvim_set_current_buf(task_bufnr)
set_win_options(vim.api.nvim_get_current_win())
M.render(task_bufnr)
return task_bufnr
end
return M

42
lua/pending/config.lua Normal file
View file

@ -0,0 +1,42 @@
---@class pending.GcalConfig
---@field calendar? string
---@field credentials_path? string
---@class pending.Config
---@field data_path string
---@field default_view 'category'|'priority'
---@field default_category string
---@field date_format string
---@field date_syntax string
---@field gcal? task.GcalConfig
---@class pending.config
local M = {}
---@type pending.Config
local defaults = {
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
default_view = 'category',
default_category = 'Inbox',
date_format = '%b %d',
date_syntax = 'due',
}
---@type pending.Config?
local _resolved = nil
---@return pending.Config
function M.get()
if _resolved then
return _resolved
end
local user = vim.g.pending or {}
_resolved = vim.tbl_deep_extend('force', defaults, user)
return _resolved
end
function M.reset()
_resolved = nil
end
return M

147
lua/pending/diff.lua Normal file
View file

@ -0,0 +1,147 @@
local config = require('pending.config')
local parse = require('pending.parse')
local store = require('pending.store')
---@class pending.ParsedEntry
---@field type 'task'|'header'|'blank'
---@field id? integer
---@field description? string
---@field priority? integer
---@field category? string
---@field due? string
---@field lnum integer
---@class pending.diff
local M = {}
---@return string
local function timestamp()
return os.date('!%Y-%m-%dT%H:%M:%SZ')
end
---@param lines string[]
---@return pending.ParsedEntry[]
function M.parse_buffer(lines)
local result = {}
local current_category = nil
for i, line in ipairs(lines) do
local id, body = line:match('^/(%d+)/( .+)$')
if not id then
body = line:match('^( .+)$')
end
if line == '' then
table.insert(result, { type = 'blank', lnum = i })
elseif id or body then
local stripped = body:match('^ (.+)$') or body
local priority = 0
if stripped:match('^! ') then
priority = 1
stripped = stripped:sub(3)
end
local description, metadata = parse.body(stripped)
if description and description ~= '' then
table.insert(result, {
type = 'task',
id = id and tonumber(id) or nil,
description = description,
priority = priority,
category = metadata.cat or current_category or config.get().default_category,
due = metadata.due,
lnum = i,
})
end
elseif line:match('^%S') then
current_category = line
table.insert(result, { type = 'header', category = line, lnum = i })
end
end
return result
end
---@param lines string[]
function M.apply(lines)
local parsed = M.parse_buffer(lines)
local now = timestamp()
local data = store.data()
local old_by_id = {}
for _, task in ipairs(data.tasks) do
if task.status ~= 'deleted' then
old_by_id[task.id] = task
end
end
local seen_ids = {}
local order_counter = 0
for _, entry in ipairs(parsed) do
if entry.type ~= 'task' then
goto continue
end
order_counter = order_counter + 1
if entry.id and old_by_id[entry.id] then
if seen_ids[entry.id] then
store.add({
description = entry.description,
category = entry.category,
priority = entry.priority,
due = entry.due,
order = order_counter,
})
else
seen_ids[entry.id] = true
local task = old_by_id[entry.id]
local changed = false
if task.description ~= entry.description then
task.description = entry.description
changed = true
end
if task.category ~= entry.category then
task.category = entry.category
changed = true
end
if task.priority ~= entry.priority then
task.priority = entry.priority
changed = true
end
if task.due ~= entry.due then
task.due = entry.due
changed = true
end
if task.order ~= order_counter then
task.order = order_counter
changed = true
end
if changed then
task.modified = now
end
end
else
store.add({
description = entry.description,
category = entry.category,
priority = entry.priority,
due = entry.due,
order = order_counter,
})
end
::continue::
end
for id, task in pairs(old_by_id) do
if not seen_ids[id] then
task.status = 'deleted'
task['end'] = now
task.modified = now
end
end
store.save()
end
return M

55
lua/pending/health.lua Normal file
View file

@ -0,0 +1,55 @@
local M = {}
function M.check()
vim.health.start('pending.nvim')
local ok, config = pcall(require, 'pending.config')
if not ok then
vim.health.error('Failed to load pending.config')
return
end
local cfg = config.get()
vim.health.ok('Config loaded')
vim.health.info('Data path: ' .. cfg.data_path)
vim.health.info('Default view: ' .. cfg.default_view)
vim.health.info('Default category: ' .. cfg.default_category)
vim.health.info('Date format: ' .. cfg.date_format)
vim.health.info('Date syntax: ' .. cfg.date_syntax)
local data_dir = vim.fn.fnamemodify(cfg.data_path, ':h')
if vim.fn.isdirectory(data_dir) == 1 then
vim.health.ok('Data directory exists: ' .. data_dir)
else
vim.health.warn('Data directory does not exist yet: ' .. data_dir)
end
if vim.fn.filereadable(cfg.data_path) == 1 then
local store_ok, store = pcall(require, 'pending.store')
if store_ok then
local load_ok, err = pcall(store.load)
if load_ok then
local tasks = store.tasks()
vim.health.ok('Data file loaded: ' .. #tasks .. ' tasks')
else
vim.health.error('Failed to load data file: ' .. tostring(err))
end
end
else
vim.health.info('No data file yet (will be created on first save)')
end
if vim.fn.executable('curl') == 1 then
vim.health.ok('curl found (required for Google Calendar sync)')
else
vim.health.warn('curl not found (needed for Google Calendar sync)')
end
if vim.fn.executable('openssl') == 1 then
vim.health.ok('openssl found (required for OAuth PKCE)')
else
vim.health.warn('openssl not found (needed for Google Calendar OAuth)')
end
end
return M

264
lua/pending/init.lua Normal file
View file

@ -0,0 +1,264 @@
local buffer = require('pending.buffer')
local diff = require('pending.diff')
local parse = require('pending.parse')
local store = require('pending.store')
---@class task
local M = {}
---@type pending.Task[]?
local _undo_state = nil
---@return integer bufnr
function M.open()
local bufnr = buffer.open()
M._setup_autocmds(bufnr)
M._setup_buf_mappings(bufnr)
return bufnr
end
---@param bufnr integer
function M._setup_autocmds(bufnr)
local group = vim.api.nvim_create_augroup('PendingBuffer', { clear = true })
vim.api.nvim_create_autocmd('BufWriteCmd', {
group = group,
buffer = bufnr,
callback = function()
M._on_write(bufnr)
end,
})
end
---@param bufnr integer
function M._setup_buf_mappings(bufnr)
local opts = { buffer = bufnr, silent = true }
vim.keymap.set('n', '<CR>', function()
M.toggle_complete()
end, opts)
vim.keymap.set('n', '<Tab>', function()
buffer.toggle_view()
end, opts)
vim.keymap.set('n', 'g?', function()
M.show_help()
end, opts)
end
---@param bufnr integer
function M._on_write(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
_undo_state = store.active_tasks()
diff.apply(lines)
buffer.render(bufnr)
end
function M.toggle_complete()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then
return
end
local id = meta[row].id
local task = store.get(id)
if not task then
return
end
if task.status == 'done' then
store.update(id, { status = 'pending', ['end'] = vim.NIL })
else
store.update(id, { status = 'done' })
end
store.save()
buffer.render(bufnr)
end
function M.toggle_priority()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then
return
end
local id = meta[row].id
local task = store.get(id)
if not task then
return
end
local new_priority = task.priority == 1 and 0 or 1
store.update(id, { priority = new_priority })
store.save()
buffer.render(bufnr)
end
function M.prompt_date()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then
return
end
local id = meta[row].id
vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input)
if not input then
return
end
local due = input ~= '' and input or nil
if due then
if not due:match('^%d%d%d%d%-%d%d%-%d%d$') then
vim.notify('Invalid date format. Use YYYY-MM-DD.', vim.log.levels.ERROR)
return
end
end
store.update(id, { due = due })
store.save()
buffer.render(bufnr)
end)
end
---@param text string
function M.add(text)
if not text or text == '' then
vim.notify('Usage: :Pending add <description>', vim.log.levels.ERROR)
return
end
store.load()
local description, metadata = parse.command_add(text)
if not description or description == '' then
vim.notify('Pending must have a description.', vim.log.levels.ERROR)
return
end
store.add({
description = description,
category = metadata.cat,
due = metadata.due,
})
store.save()
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
end
vim.notify('Pending added: ' .. description)
end
function M.sync()
local ok, gcal = pcall(require, 'pending.sync.gcal')
if not ok then
vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR)
return
end
gcal.sync()
end
---@param days? integer
function M.archive(days)
days = days or 30
local cutoff = os.time() - (days * 86400)
local tasks = store.tasks()
local archived = 0
local kept = {}
for _, task in ipairs(tasks) do
if (task.status == 'done' or task.status == 'deleted') and task['end'] then
local y, mo, d, h, mi, s = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$')
if y then
local t = os.time({
year = tonumber(y),
month = tonumber(mo),
day = tonumber(d),
hour = tonumber(h),
min = tonumber(mi),
sec = tonumber(s),
})
if t < cutoff then
archived = archived + 1
goto skip
end
end
end
table.insert(kept, task)
::skip::
end
store.replace_tasks(kept)
store.save()
vim.notify('Archived ' .. archived .. ' tasks.')
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
end
end
function M.show_help()
local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due'
local lines = {
'pending.nvim keybindings',
'',
'<CR> Toggle complete/uncomplete',
'<Tab> Switch category/priority view',
'o / O Add new task line',
'dd Delete task (on :w)',
'p / P Paste (duplicates get new IDs)',
':w Save all changes',
'',
':Pending add <text> Quick-add task',
':Pending add Cat: <text> Quick-add with category',
':Pending sync Push to Google Calendar',
':Pending archive [days] Purge old done tasks',
'',
'Inline metadata (on new lines before :w):',
' ' .. dk .. ':YYYY-MM-DD Set due date',
' cat:Name Set category',
'',
'Press q or <Esc> to close',
}
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].modifiable = false
vim.bo[buf].bufhidden = 'wipe'
local width = 50
local height = #lines
local win = vim.api.nvim_open_win(buf, true, {
relative = 'editor',
width = width,
height = height,
col = math.floor((vim.o.columns - width) / 2),
row = math.floor((vim.o.lines - height) / 2),
style = 'minimal',
border = 'rounded',
})
vim.keymap.set('n', 'q', function()
vim.api.nvim_win_close(win, true)
end, { buffer = buf, silent = true })
vim.keymap.set('n', '<Esc>', function()
vim.api.nvim_win_close(win, true)
end, { buffer = buf, silent = true })
end
---@param args string
function M.command(args)
if not args or args == '' then
M.open()
return
end
local cmd, rest = args:match('^(%S+)%s*(.*)')
if cmd == 'add' then
M.add(rest)
elseif cmd == 'sync' then
M.sync()
elseif cmd == 'archive' then
local d = rest ~= '' and tonumber(rest) or nil
M.archive(d)
else
vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR)
end
end
return M

98
lua/pending/parse.lua Normal file
View file

@ -0,0 +1,98 @@
local config = require('pending.config')
---@class pending.parse
local M = {}
---@param s string
---@return boolean
local function is_valid_date(s)
local y, m, d = s:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
if not y then
return false
end
y, m, d = tonumber(y), tonumber(m), tonumber(d)
if m < 1 or m > 12 then
return false
end
if d < 1 or d > 31 then
return false
end
local t = os.time({ year = y, month = m, day = d })
local check = os.date('*t', t)
return check.year == y and check.month == m and check.day == d
end
---@return string
local function date_key()
return config.get().date_syntax or 'due'
end
---@param text string
---@return string description
---@return { due?: string, cat?: string } metadata
function M.body(text)
local tokens = {}
for token in text:gmatch('%S+') do
table.insert(tokens, token)
end
local metadata = {}
local i = #tokens
local dk = date_key()
local date_pattern = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$'
while i >= 1 do
local token = tokens[i]
local due_val = token:match(date_pattern)
if due_val then
if metadata.due then
break
end
if not is_valid_date(due_val) then
break
end
metadata.due = due_val
i = i - 1
else
local cat_val = token:match('^cat:(%S+)$')
if cat_val then
if metadata.cat then
break
end
metadata.cat = cat_val
i = i - 1
else
break
end
end
end
local desc_tokens = {}
for j = 1, i do
table.insert(desc_tokens, tokens[j])
end
local description = table.concat(desc_tokens, ' ')
return description, metadata
end
---@param text string
---@return string description
---@return { due?: string, cat?: string } metadata
function M.command_add(text)
local cat_prefix = text:match('^(%S.-):%s')
if cat_prefix then
local first_char = cat_prefix:sub(1, 1)
if first_char == first_char:upper() and first_char ~= first_char:lower() then
local rest = text:sub(#cat_prefix + 2):match('^%s*(.+)$')
if rest then
local desc, meta = M.body(rest)
meta.cat = meta.cat or cat_prefix
return desc, meta
end
end
end
return M.body(text)
end
return M

296
lua/pending/store.lua Normal file
View file

@ -0,0 +1,296 @@
local config = require('pending.config')
---@class pending.Task
---@field id integer
---@field description string
---@field status 'pending'|'done'|'deleted'
---@field category? string
---@field priority integer
---@field due? string
---@field entry string
---@field modified string
---@field end? string
---@field order integer
---@field _extra? table<string, any>
---@class pending.Data
---@field version integer
---@field next_id integer
---@field tasks task.Task[]
---@class pending.store
local M = {}
local SUPPORTED_VERSION = 1
---@type pending.Data?
local _data = nil
---@return pending.Data
local function empty_data()
return {
version = SUPPORTED_VERSION,
next_id = 1,
tasks = {},
}
end
---@param path string
local function ensure_dir(path)
local dir = vim.fn.fnamemodify(path, ':h')
if vim.fn.isdirectory(dir) == 0 then
vim.fn.mkdir(dir, 'p')
end
end
---@return string
local function timestamp()
return os.date('!%Y-%m-%dT%H:%M:%SZ')
end
---@type table<string, true>
local known_fields = {
id = true,
description = true,
status = true,
category = true,
priority = true,
due = true,
entry = true,
modified = true,
['end'] = true,
order = true,
}
---@param task pending.Task
---@return table
local function task_to_table(task)
local t = {
id = task.id,
description = task.description,
status = task.status,
entry = task.entry,
modified = task.modified,
}
if task.category then
t.category = task.category
end
if task.priority and task.priority ~= 0 then
t.priority = task.priority
end
if task.due then
t.due = task.due
end
if task['end'] then
t['end'] = task['end']
end
if task.order and task.order ~= 0 then
t.order = task.order
end
if task._extra then
for k, v in pairs(task._extra) do
t[k] = v
end
end
return t
end
---@param t table
---@return pending.Task
local function table_to_task(t)
local task = {
id = t.id,
description = t.description,
status = t.status or 'pending',
category = t.category,
priority = t.priority or 0,
due = t.due,
entry = t.entry,
modified = t.modified,
['end'] = t['end'],
order = t.order or 0,
_extra = {},
}
for k, v in pairs(t) do
if not known_fields[k] then
task._extra[k] = v
end
end
if next(task._extra) == nil then
task._extra = nil
end
return task
end
---@return pending.Data
function M.load()
local path = config.get().data_path
local f = io.open(path, 'r')
if not f then
_data = empty_data()
return _data
end
local content = f:read('*a')
f:close()
if content == '' then
_data = empty_data()
return _data
end
local ok, decoded = pcall(vim.json.decode, content)
if not ok then
error('pending.nvim: failed to parse ' .. path .. ': ' .. tostring(decoded))
end
if decoded.version and decoded.version > SUPPORTED_VERSION then
error(
'pending.nvim: data file version '
.. decoded.version
.. ' is newer than supported version '
.. SUPPORTED_VERSION
.. '. Please update the plugin.'
)
end
_data = {
version = decoded.version or SUPPORTED_VERSION,
next_id = decoded.next_id or 1,
tasks = {},
}
for _, t in ipairs(decoded.tasks or {}) do
table.insert(_data.tasks, table_to_task(t))
end
return _data
end
function M.save()
if not _data then
return
end
local path = config.get().data_path
ensure_dir(path)
local out = {
version = _data.version,
next_id = _data.next_id,
tasks = {},
}
for _, task in ipairs(_data.tasks) do
table.insert(out.tasks, task_to_table(task))
end
local encoded = vim.json.encode(out)
local f = io.open(path, 'w')
if not f then
error('pending.nvim: cannot write to ' .. path)
end
f:write(encoded)
f:close()
end
---@return pending.Data
function M.data()
if not _data then
M.load()
end
return _data
end
---@return pending.Task[]
function M.tasks()
return M.data().tasks
end
---@return pending.Task[]
function M.active_tasks()
local result = {}
for _, task in ipairs(M.tasks()) do
if task.status ~= 'deleted' then
table.insert(result, task)
end
end
return result
end
---@param id integer
---@return pending.Task?
function M.get(id)
for _, task in ipairs(M.tasks()) do
if task.id == id then
return task
end
end
return nil
end
---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, order?: integer, _extra?: table }
---@return pending.Task
function M.add(fields)
local data = M.data()
local now = timestamp()
local task = {
id = data.next_id,
description = fields.description,
status = fields.status or 'pending',
category = fields.category or config.get().default_category,
priority = fields.priority or 0,
due = fields.due,
entry = now,
modified = now,
['end'] = nil,
order = fields.order or 0,
_extra = fields._extra,
}
data.next_id = data.next_id + 1
table.insert(data.tasks, task)
return task
end
---@param id integer
---@param fields table<string, any>
---@return pending.Task?
function M.update(id, fields)
local task = M.get(id)
if not task then
return nil
end
local now = timestamp()
for k, v in pairs(fields) do
if k ~= 'id' and k ~= 'entry' then
task[k] = v
end
end
task.modified = now
if fields.status == 'done' or fields.status == 'deleted' then
task['end'] = task['end'] or now
end
return task
end
---@param id integer
---@return pending.Task?
function M.delete(id)
return M.update(id, { status = 'deleted', ['end'] = timestamp() })
end
---@param id integer
---@return integer?
function M.find_index(id)
for i, task in ipairs(M.tasks()) do
if task.id == id then
return i
end
end
return nil
end
---@param tasks pending.Task[]
function M.replace_tasks(tasks)
M.data().tasks = tasks
end
---@param id integer
function M.set_next_id(id)
M.data().next_id = id
end
function M.unload()
_data = nil
end
return M

453
lua/pending/sync/gcal.lua Normal file
View file

@ -0,0 +1,453 @@
local config = require('pending.config')
local store = require('pending.store')
local M = {}
local BASE_URL = 'https://www.googleapis.com/calendar/v3'
local TOKEN_URL = 'https://oauth2.googleapis.com/token'
local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
local SCOPE = 'https://www.googleapis.com/auth/calendar'
local function gcal_config()
local cfg = config.get()
return cfg.gcal or {}
end
local function token_path()
return vim.fn.stdpath('data') .. '/pending/gcal_tokens.json'
end
local function credentials_path()
local gc = gcal_config()
return gc.credentials_path or (vim.fn.stdpath('data') .. '/pending/gcal_credentials.json')
end
local function load_json_file(path)
local f = io.open(path, 'r')
if not f then
return nil
end
local content = f:read('*a')
f:close()
if content == '' then
return nil
end
local ok, decoded = pcall(vim.json.decode, content)
if not ok then
return nil
end
return decoded
end
local function save_json_file(path, data)
local dir = vim.fn.fnamemodify(path, ':h')
if vim.fn.isdirectory(dir) == 0 then
vim.fn.mkdir(dir, 'p')
end
local f = io.open(path, 'w')
if not f then
return false
end
f:write(vim.json.encode(data))
f:close()
vim.fn.setfperm(path, 'rw-------')
return true
end
local function load_credentials()
local creds = load_json_file(credentials_path())
if not creds then
return nil
end
if creds.installed then
return creds.installed
end
return creds
end
local function load_tokens()
return load_json_file(token_path())
end
local function save_tokens(tokens)
return save_json_file(token_path(), tokens)
end
local function url_encode(str)
return str:gsub('([^%w%-%.%_%~])', function(c)
return string.format('%%%02X', string.byte(c))
end)
end
local function curl_request(method, url, headers, body)
local args = { 'curl', '-s', '-X', method }
for _, h in ipairs(headers or {}) do
table.insert(args, '-H')
table.insert(args, h)
end
if body then
table.insert(args, '-d')
table.insert(args, body)
end
table.insert(args, url)
local result = vim.system(args, { text = true }):wait()
if result.code ~= 0 then
return nil, 'curl failed: ' .. (result.stderr or '')
end
if not result.stdout or result.stdout == '' then
return {}, nil
end
local ok, decoded = pcall(vim.json.decode, result.stdout)
if not ok then
return nil, 'failed to parse response: ' .. result.stdout
end
if decoded.error then
return nil, 'API error: ' .. (decoded.error.message or vim.json.encode(decoded.error))
end
return decoded, nil
end
local function auth_headers(access_token)
return {
'Authorization: Bearer ' .. access_token,
'Content-Type: application/json',
}
end
local function refresh_access_token(creds, tokens)
local body = 'client_id='
.. url_encode(creds.client_id)
.. '&client_secret='
.. url_encode(creds.client_secret)
.. '&grant_type=refresh_token'
.. '&refresh_token='
.. url_encode(tokens.refresh_token)
local result = vim
.system({
'curl',
'-s',
'-X',
'POST',
'-H',
'Content-Type: application/x-www-form-urlencoded',
'-d',
body,
TOKEN_URL,
}, { text = true })
:wait()
if result.code ~= 0 then
return nil
end
local ok, decoded = pcall(vim.json.decode, result.stdout or '')
if not ok or not decoded.access_token then
return nil
end
tokens.access_token = decoded.access_token
tokens.expires_in = decoded.expires_in
tokens.obtained_at = os.time()
save_tokens(tokens)
return tokens
end
local function get_access_token()
local creds = load_credentials()
if not creds then
vim.notify(
'pending.nvim: No Google Calendar credentials found at ' .. credentials_path(),
vim.log.levels.ERROR
)
return nil
end
local tokens = load_tokens()
if not tokens or not tokens.refresh_token then
M.authorize()
tokens = load_tokens()
if not tokens then
return nil
end
end
local now = os.time()
local obtained = tokens.obtained_at or 0
local expires = tokens.expires_in or 3600
if now - obtained > expires - 60 then
tokens = refresh_access_token(creds, tokens)
if not tokens then
vim.notify('pending.nvim: Failed to refresh access token.', vim.log.levels.ERROR)
return nil
end
end
return tokens.access_token
end
function M.authorize()
local creds = load_credentials()
if not creds then
vim.notify(
'pending.nvim: No Google Calendar credentials found at ' .. credentials_path(),
vim.log.levels.ERROR
)
return
end
local port = 18392
local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
local verifier = {}
math.randomseed(os.time())
for _ = 1, 64 do
local idx = math.random(1, #verifier_chars)
table.insert(verifier, verifier_chars:sub(idx, idx))
end
local code_verifier = table.concat(verifier)
local sha_pipe = vim
.system({
'sh',
'-c',
'printf "%s" "'
.. code_verifier
.. '" | openssl dgst -sha256 -binary | openssl base64 -A | tr "+/" "-_" | tr -d "="',
}, { text = true })
:wait()
local code_challenge = sha_pipe.stdout or ''
local auth_url = AUTH_URL
.. '?client_id='
.. url_encode(creds.client_id)
.. '&redirect_uri='
.. url_encode('http://127.0.0.1:' .. port)
.. '&response_type=code'
.. '&scope='
.. url_encode(SCOPE)
.. '&access_type=offline'
.. '&prompt=consent'
.. '&code_challenge='
.. url_encode(code_challenge)
.. '&code_challenge_method=S256'
vim.ui.open(auth_url)
vim.notify('pending.nvim: Opening browser for Google authorization...')
local server = vim.uv.new_tcp()
server:bind('127.0.0.1', port)
server:listen(1, function(err)
if err then
return
end
local client = vim.uv.new_tcp()
server:accept(client)
client:read_start(function(read_err, data)
if read_err or not data then
return
end
local code = data:match('[?&]code=([^&%s]+)')
local response_body = code
and '<html><body><h1>Authorization successful</h1><p>You can close this tab.</p></body></html>'
or '<html><body><h1>Authorization failed</h1></body></html>'
local http_response = 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n'
.. response_body
client:write(http_response, function()
client:shutdown(function()
client:close()
end)
end)
server:close()
if code then
vim.schedule(function()
M._exchange_code(creds, code, code_verifier, port)
end)
end
end)
end)
end
function M._exchange_code(creds, code, code_verifier, port)
local body = 'client_id='
.. url_encode(creds.client_id)
.. '&client_secret='
.. url_encode(creds.client_secret)
.. '&code='
.. url_encode(code)
.. '&code_verifier='
.. url_encode(code_verifier)
.. '&grant_type=authorization_code'
.. '&redirect_uri='
.. url_encode('http://127.0.0.1:' .. port)
local result = vim
.system({
'curl',
'-s',
'-X',
'POST',
'-H',
'Content-Type: application/x-www-form-urlencoded',
'-d',
body,
TOKEN_URL,
}, { text = true })
:wait()
if result.code ~= 0 then
vim.notify('pending.nvim: Token exchange failed.', vim.log.levels.ERROR)
return
end
local ok, decoded = pcall(vim.json.decode, result.stdout or '')
if not ok or not decoded.access_token then
vim.notify('pending.nvim: Invalid token response.', vim.log.levels.ERROR)
return
end
decoded.obtained_at = os.time()
save_tokens(decoded)
vim.notify('pending.nvim: Google Calendar authorized successfully.')
end
local function find_or_create_calendar(access_token)
local gc = gcal_config()
local cal_name = gc.calendar or 'Pendings'
local data, err =
curl_request('GET', BASE_URL .. '/users/me/calendarList', auth_headers(access_token))
if err then
return nil, err
end
for _, item in ipairs(data.items or {}) do
if item.summary == cal_name then
return item.id, nil
end
end
local body = vim.json.encode({ summary = cal_name })
local created, create_err =
curl_request('POST', BASE_URL .. '/calendars', auth_headers(access_token), body)
if create_err then
return nil, create_err
end
return created.id, nil
end
local function next_day(date_str)
local y, m, d = date_str:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) + 86400
return os.date('%Y-%m-%d', t)
end
local function create_event(access_token, calendar_id, task)
local event = {
summary = task.description,
start = { date = task.due },
['end'] = { date = next_day(task.due) },
transparency = 'transparent',
extendedProperties = {
private = { taskId = tostring(task.id) },
},
}
local data, err = curl_request(
'POST',
BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events',
auth_headers(access_token),
vim.json.encode(event)
)
if err then
return nil, err
end
return data.id, nil
end
local function update_event(access_token, calendar_id, event_id, task)
local event = {
summary = task.description,
start = { date = task.due },
['end'] = { date = next_day(task.due) },
}
local _, err = curl_request(
'PATCH',
BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id),
auth_headers(access_token),
vim.json.encode(event)
)
return err
end
local function delete_event(access_token, calendar_id, event_id)
local _, err = curl_request(
'DELETE',
BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id),
auth_headers(access_token)
)
return err
end
function M.sync()
local access_token = get_access_token()
if not access_token then
return
end
local calendar_id, err = find_or_create_calendar(access_token)
if err then
vim.notify('pending.nvim: ' .. err, vim.log.levels.ERROR)
return
end
local tasks = store.tasks()
local created, updated, deleted = 0, 0, 0
for _, task in ipairs(tasks) do
local extra = task._extra or {}
local event_id = extra._gcal_event_id
local should_delete = event_id
and (
task.status == 'done'
or task.status == 'deleted'
or (task.status == 'pending' and not task.due)
)
if should_delete then
local del_err = delete_event(access_token, calendar_id, event_id)
if not del_err then
extra._gcal_event_id = nil
if next(extra) == nil then
task._extra = nil
else
task._extra = extra
end
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ')
deleted = deleted + 1
end
elseif task.status == 'pending' and task.due then
if event_id then
local upd_err = update_event(access_token, calendar_id, event_id, task)
if not upd_err then
updated = updated + 1
end
else
local new_id, create_err = create_event(access_token, calendar_id, task)
if not create_err and new_id then
if not task._extra then
task._extra = {}
end
task._extra._gcal_event_id = new_id
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ')
created = created + 1
end
end
end
end
store.save()
vim.notify(
string.format(
'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)',
created,
updated,
deleted
)
)
end
return M

181
lua/pending/views.lua Normal file
View file

@ -0,0 +1,181 @@
local config = require('pending.config')
---@class pending.LineMeta
---@field type 'task'|'header'|'blank'
---@field id? integer
---@field due? string
---@field raw_due? string
---@field status? string
---@field category? string
---@class pending.views
local M = {}
---@param due? string
---@return string?
local function format_due(due)
if not due then
return nil
end
local y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
if not y then
return due
end
local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) })
return os.date(config.get().date_format, t)
end
---@param tasks pending.Task[]
local function sort_tasks(tasks)
table.sort(tasks, function(a, b)
if a.priority ~= b.priority then
return a.priority > b.priority
end
if a.order ~= b.order then
return a.order < b.order
end
return a.id < b.id
end)
end
---@param tasks pending.Task[]
local function sort_tasks_priority(tasks)
table.sort(tasks, function(a, b)
if a.priority ~= b.priority then
return a.priority > b.priority
end
local a_due = a.due or ''
local b_due = b.due or ''
if a_due ~= b_due then
if a_due == '' then
return false
end
if b_due == '' then
return true
end
return a_due < b_due
end
if a.order ~= b.order then
return a.order < b.order
end
return a.id < b.id
end)
end
---@param tasks pending.Task[]
---@return string[] lines
---@return pending.LineMeta[] meta
function M.category_view(tasks)
local by_cat = {}
local cat_order = {}
local cat_seen = {}
local done_by_cat = {}
for _, task in ipairs(tasks) do
local cat = task.category or config.get().default_category
if not cat_seen[cat] then
cat_seen[cat] = true
table.insert(cat_order, cat)
by_cat[cat] = {}
done_by_cat[cat] = {}
end
if task.status == 'done' then
table.insert(done_by_cat[cat], task)
else
table.insert(by_cat[cat], task)
end
end
for _, cat in ipairs(cat_order) do
sort_tasks(by_cat[cat])
sort_tasks(done_by_cat[cat])
end
local lines = {}
local meta = {}
for i, cat in ipairs(cat_order) do
if i > 1 then
table.insert(lines, '')
table.insert(meta, { type = 'blank' })
end
table.insert(lines, cat)
table.insert(meta, { type = 'header', category = cat })
local all = {}
for _, t in ipairs(by_cat[cat]) do
table.insert(all, t)
end
for _, t in ipairs(done_by_cat[cat]) do
table.insert(all, t)
end
for _, task in ipairs(all) do
local prefix = '/' .. task.id .. '/'
local indent = ' '
local prio = task.priority == 1 and '! ' or ''
local line = prefix .. indent .. prio .. task.description
table.insert(lines, line)
table.insert(meta, {
type = 'task',
id = task.id,
due = format_due(task.due),
raw_due = task.due,
status = task.status,
category = cat,
})
end
end
return lines, meta
end
---@param tasks pending.Task[]
---@return string[] lines
---@return pending.LineMeta[] meta
function M.priority_view(tasks)
local pending = {}
local done = {}
for _, task in ipairs(tasks) do
if task.status == 'done' then
table.insert(done, task)
else
table.insert(pending, task)
end
end
sort_tasks_priority(pending)
sort_tasks_priority(done)
local lines = {}
local meta = {}
local all = {}
for _, t in ipairs(pending) do
table.insert(all, t)
end
for _, t in ipairs(done) do
table.insert(all, t)
end
for _, task in ipairs(all) do
local prefix = '/' .. task.id .. '/'
local indent = ' '
local prio = task.priority == 1 and '! ' or ''
local line = prefix .. indent .. prio .. task.description
table.insert(lines, line)
table.insert(meta, {
type = 'task',
id = task.id,
due = format_due(task.due),
raw_due = task.due,
status = task.status,
category = task.category,
})
end
return lines, meta
end
return M