diff --git a/README.md b/README.md index 3a5f0ec..cb3dd2d 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# agenda.nvim +# agenda-nvim diff --git a/doc/agenda.txt b/doc/agenda.txt new file mode 100644 index 0000000..5713dc3 --- /dev/null +++ b/doc/agenda.txt @@ -0,0 +1,25 @@ +*agenda.txt* + +*Agenda* + +Neovim integration for the `agenda` +edit subcommand. + +To open an agenda note file: >vim + + Agenda last monday + +If the agenda note does not exist, you can create it automatically: >vim + + Agenda! last monday + +The date spec can be any string understood by your `date(1)` util. + +The command accepts the b, c, e, and t options of the `agenda edit` +subcommand: >vim + + Agenda [-bc] [-e ] [-t ] last monday + +See `:Man agenda` for additional information. + +vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/lua/agenda.lua b/lua/agenda.lua new file mode 100644 index 0000000..2d79db1 --- /dev/null +++ b/lua/agenda.lua @@ -0,0 +1,132 @@ +local api = vim.api +local fn = vim.fn + +---Wrapper for using my `agenda` script from within neovim. +local M = {} + +function M.on_attach(buf) + vim.bo[buf].bufhidden = 'delete' +end + +---@return string +function M.dir() + if vim.env.AGENDA_DIR then + return vim.env.AGENDA_DIR + end + + if vim.env.XDG_DATA_HOME then + return string.format('%/agenda', vim.env.XDG_DATA_HOME) + end + + return string.format('%/.local/share/agenda', vim.env.HOME) +end + +---Gets the first window with an agenda note buffer in a tabpage. +---@param tabpage integer Tabpage handle, or 0 for the current tabpage. +---@param opts {dir: string?}? +---@return integer? win Window handle +function M.tabpage_get_win(tabpage, opts) + local agenda_dir = opts and opts.dir or M.dir() + + for _, win in ipairs(api.nvim_tabpage_list_wins(tabpage)) do + local buf = api.nvim_win_get_buf(win) + -- Better to look at the name (file path), since that state is always there + -- (e.g. after restoring a session). + local name = api.nvim_buf_get_name(buf) + if vim.startswith(name, agenda_dir) then + return win + end + end +end + +---@class agenda.Opts +---@field backlog string? Set the -b flag. +---@field create boolean? Set the -c flag. +---@field dir string? Set this as AGENDA_DIR value. +---@field extension string? Set this as -e option value. +---@field name string? Set this as -t option value. + +---Open an agenda note. +---@param date string Either the date or backlog name. +---@param opts agenda.Opts? +function M.open(date, opts) + local filepath, err = M.agenda(date, opts) + + if not filepath or fn.filereadable(filepath) < 1 then + if not err then + err = string.format("agenda: file '%s' is not readable", filepath) + end + vim.notify(err, vim.log.levels.WARN) + return + end + + local win = M.tabpage_get_win(0) + if win then + vim.api.nvim_set_current_win(win) + vim.cmd.edit(filepath) + else + vim.cmd.split(filepath) + end + + M.on_attach(0) +end + +---Query an agenda note's file path. +---@param date string Either the date or backlog name. +---@param opts agenda.Opts? +---@return string? filepath +---@return string? err +function M.agenda(date, opts) + local env = { + -- An empty environment table is invalid. + AGENDA_DIR = vim.env.AGENDA_DIR + } + local cmd = { 'agenda', '-E' } + + if opts then + if opts.dir then + env.AGENDA_DIR = opts.dir + end + + if opts.backlog then + cmd[#cmd + 1] = '-b' + end + + if opts.create then + cmd[#cmd + 1] = '-c' + end + + if opts.extension then + cmd[#cmd + 1] = '-e' + cmd[#cmd + 1] = opts.extension + end + + if opts.name then + cmd[#cmd + 1] = '-t' + cmd[#cmd + 1] = opts.name + end + end + + cmd[#cmd + 1] = date + + local filepath = '' + local err = '' + local pid = fn.jobstart(cmd, { + env = env, + on_stdout = function(_, data) + filepath = filepath .. data[1] + end, + on_stderr = function(_, data) + err = err .. data[1] + end, + }) + + local code = unpack(fn.jobwait { pid }) + if code > 0 then + return nil, err + end + + return filepath +end + +return M diff --git a/lua/agenda/util.lua b/lua/agenda/util.lua new file mode 100644 index 0000000..072e031 --- /dev/null +++ b/lua/agenda/util.lua @@ -0,0 +1,78 @@ +local M = {} + +---Parses a list of arguments **kind of** like the getopts shell builtin. +---@param args string[] +---@param optstring string +---@return function +function M.getopts(args, optstring) + local spec = {} + local last + + for i = 1, #optstring do + local c = optstring:sub(i, i) + + if c == ':' and last then + spec[last] = true + elseif i == #optstring then + spec[c] = false + elseif last and last ~= ':' then + spec[last] = false + end + + last = c + end + + -- 1. if the option takes a value either + -- a. take the rest of the slice as its values if at least one char + -- remains in the slice + -- b. take the next whole argument as its value + -- 2. find the next option name (char) + local i = 1 -- arg index + local n = 1 -- slice index + local g = 1 -- I am not very smart + return function () + while args[i] do + local arg = args[i] + + -- TODO: Maybe write some tests instead :^) + if g > 2097152 then + error 'logic error' + end + g = g + 1 + + if n == 1 and not vim.startswith(arg, '-') then + -- "Normal" arguments end the iterator. + return + end + + n = n + 1 + local c = arg:sub(n, n) + if #c > n then + n = n + 1 + end + + if #c == 1 then + if spec[c] then + local optval = arg:sub(n + 1, #arg) + n = 1 + i = i + 1 + if #optval > 0 then + return i, c, optval + end + -- Remaining argument does not contain optval, consume the next argument + -- as optval. + i = i + 1 + return i, c, args[i - 1] + end + + -- Do not handle nil. If the opt is invalid the caller must handle it. + return i + 1, c, nil + end + + -- Stray '-' ends the iterator. + return + end + end +end + +return M diff --git a/plugin/agenda.lua b/plugin/agenda.lua new file mode 100644 index 0000000..e25f854 --- /dev/null +++ b/plugin/agenda.lua @@ -0,0 +1,121 @@ +local agenda = require 'agenda' +local getopts = require 'agenda.util'.getopts + +-- If neovim was started by `agenda` the `AGENDA_FILE` env var will be set +-- with the file name. +if vim.env.AGENDA_FILE == vim.api.nvim_buf_get_name(0) then + agenda.on_attach(0) +end + +-- Callbacks in the middle of a function signature are PAIN. +local function create_user_command(name, opts) + local command = opts.command + opts.command = nil + vim.api.nvim_create_user_command(name, command, opts) +end + +-- TODO: Handle subcommands (kinda like git-fugitive)? Could be useful if I'll +-- add a remove subcommand. +-- TODO: Handle -E option (why not?). +create_user_command('Agenda', { + desc = 'Open a note file/buffer for the given day or the current day', + nargs = '*', + bang = true, + bar = true, + command = function (command) + local opts = { + create = command.bang, + } + + local i = 1 + for optind, opt, optval in getopts(command.fargs, 'bce:t:') do + if opt == 'b' then + opts.backlog = true + elseif opt == 'c' then + opts.create = true + elseif opt == 'e' then + opts.extension = optval + elseif opt == 't' then + opts.name = optval + else + local err = string.format("agenda: invalid option: '%s'", opt) + vim.notify(err, vim.log.levels.WARN) + return + end + + i = optind + end + + local args = {} + while command.fargs[i] do + args[#args + 1] = command.fargs[i] + i = i + 1 + end + + local date + if #args > 0 then + date = table.concat(args, ' ') + else + date = opts.backlog and 'backlog' or 'today' + end + + agenda.open(date, opts) + end, + -- TODO: Handle supported agenda edit options. + complete = function (arg_lead, cmd_line, cursor_pos) + local modifiers = { + next = true, + last = true, + } + + local completions = {} + + local final = {} + + if cmd_line:match '^Agenda!? [%w-]*$' then + completions = { + 'next ', + 'last ', + 'yesterday', + 'tomorrow', + 'today', + 'day', + 'week', + 'month', + 'year', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', + } + else + local first = cmd_line:match '^Agenda (%w+)%s+%w*$' + if modifiers[first] then + completions = { + 'day', + 'week', + 'month', + 'year', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', + } + end + end + + for _, can in pairs(completions) do + if vim.startswith(can, arg_lead) then + final[#final + 1] = can + end + end + + return final + end, +})