feat: rename
This commit is contained in:
parent
c69a3957c8
commit
78a275d096
21 changed files with 191 additions and 191 deletions
194
lua/pending/buffer.lua
Normal file
194
lua/pending/buffer.lua
Normal 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
42
lua/pending/config.lua
Normal 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
147
lua/pending/diff.lua
Normal 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
55
lua/pending/health.lua
Normal 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
264
lua/pending/init.lua
Normal 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
98
lua/pending/parse.lua
Normal 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
296
lua/pending/store.lua
Normal 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
453
lua/pending/sync/gcal.lua
Normal 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
181
lua/pending/views.lua
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue