Compare commits

..

2 commits

Author SHA1 Message Date
8433d92857 ci: format 2026-02-25 09:39:11 -05:00
fa1103ad4e doc: minify readme 2026-02-25 09:37:49 -05:00
25 changed files with 233 additions and 4795 deletions

View file

@ -30,16 +30,13 @@ concealed tokens and are never visible during editing.
Features: ~ Features: ~
- Oil-style buffer editing: standard Vim motions for all task operations - Oil-style buffer editing: standard Vim motions for all task operations
- Inline metadata syntax: `due:`, `cat:`, `rec:`, and `file:` tokens parsed on `:w` - Inline metadata syntax: `due:` and `cat:` tokens parsed on `:w`
- Relative date input: `today`, `tomorrow`, `+Nd`, `+Nw`, `+Nm`, weekday - Relative date input: `today`, `tomorrow`, `+Nd`, weekday names
names, month names, ordinals, and more - Two views: category (default) and priority flat list
- Recurring tasks with automatic next-date spawning on completion - Multi-level undo (up to 20 `:w` saves, session-only)
- Two views: category (default) and queue (priority-sorted flat list)
- Multi-level undo (up to 20 `:w` saves, persisted across sessions)
- Quick-add from the command line with `:Pending add` - Quick-add from the command line with `:Pending add`
- Quickfix list of overdue/due-today tasks via `:Pending due` - Quickfix list of overdue/due-today tasks via `:Pending due`
- Foldable category sections (`zc`/`zo`) in category view - Foldable category sections (`zc`/`zo`) in category view
- Omnifunc completion for `cat:`, `due:`, and `rec:` tokens (`<C-x><C-o>`)
- Google Calendar one-way push via OAuth PKCE - Google Calendar one-way push via OAuth PKCE
============================================================================== ==============================================================================
@ -47,7 +44,7 @@ REQUIREMENTS *pending-requirements*
- Neovim 0.10+ - Neovim 0.10+
- No external dependencies for local use - No external dependencies for local use
- `curl` and `openssl` are required for the `gcal` sync backend - `curl` and `openssl` are required for Google Calendar sync
============================================================================== ==============================================================================
INSTALL *pending-install* INSTALL *pending-install*
@ -98,19 +95,20 @@ parsed from the right and consumed until a non-metadata token is reached.
Supported tokens: ~ Supported tokens: ~
`due:YYYY-MM-DD` Set a due date using an absolute date. `due:YYYY-MM-DD` Set a due date using an absolute date.
`due:<name>` Resolve a named date (see |pending-dates| below). `due:today` Resolve to today's date.
`due:tomorrow` Resolve to tomorrow's date.
`due:+Nd` Resolve to N days from today (e.g. `due:+3d`).
`due:mon` Resolve to the next occurrence of that weekday.
Supported: `sun` `mon` `tue` `wed` `thu` `fri` `sat`
`cat:Name` Move the task to the named category on save. `cat:Name` Move the task to the named category on save.
`rec:<pattern>` Set a recurrence rule (see |pending-recurrence|).
`file:<path>:<n>` Attach a file reference (see |pending-file-token|).
The token name for due dates defaults to `due` and is configurable via The token name for due dates defaults to `due` and is configurable via
`date_syntax` in |pending-config|. The token name for recurrence defaults to `date_syntax` in |pending-config|. If `date_syntax` is set to `by`, write
`rec` and is configurable via `recur_syntax`. `by:2026-03-15` instead.
Example: > Example: >
Buy milk due:2026-03-15 cat:Errands Buy milk due:2026-03-15 cat:Errands
Take out trash due:monday rec:weekly
< <
On `:w`, the description becomes `Buy milk`, the due date is stored as On `:w`, the description becomes `Buy milk`, the due date is stored as
@ -118,138 +116,8 @@ On `:w`, the description becomes `Buy milk`, the due date is stored as
placed under the `Errands` category header. placed under the `Errands` category header.
Parsing stops at the first token that is not a recognised metadata token. Parsing stops at the first token that is not a recognised metadata token.
Repeated tokens of the same type also stop parsing — only one `due:`, one Repeated tokens of the same type also stop parsing — only one `due:` and one
`cat:`, one `rec:`, and one `file:` per task line are consumed. `cat:` per task line are consumed.
Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types.
In insert mode, type the token prefix and press `<C-x><C-o>` to see
suggestions.
==============================================================================
FILE TOKEN *pending-file-token*
The `file:` inline token attaches a source file reference to a task. The
syntax is: >
file:<relative-path>:<line-number>
<
The path is stored relative to the directory containing the data file. The
token is rendered as virtual text at the end of the task line, showing only
the basename and line number (e.g. `auth.lua:42`) using the |PendingFile|
highlight group.
Example: >
Fix null pointer file:src/auth.lua:42
Update tests file:spec/parse_spec.lua:100
<
`gf` in normal mode in the task buffer follows the file reference, opening
the file and jumping to the specified line. The default key is `gf` and can
be changed via the `goto_file` keymap in |pending-config|. Set it to `false`
to disable.
To attach the current file and cursor position to an existing task, invoke
|<Plug>(pending-add-here)| from any source file. A `vim.ui.select()` picker
lists all active tasks; selecting one records the current file and line.
To clear a file reference with `:Pending edit`: >vim
:Pending edit 5 -file
<
==============================================================================
DATE INPUT *pending-dates*
Named dates can be used anywhere a date is accepted: the `due:` inline
token, the `D` prompt, and `:Pending add`.
Token Resolves to ~
----- -----------
`today` Today's date
`tomorrow` Tomorrow's date
`yesterday` Yesterday's date
`eod` Today (end of day semantics)
`+Nd` N days from today (e.g. `+3d`)
`+Nw` N weeks from today (e.g. `+2w`)
`+Nm` N months from today (e.g. `+1m`)
`-Nd` N days ago (e.g. `-2d`)
`-Nw` N weeks ago (e.g. `-1w`)
`mon``sun` Next occurrence of that weekday
`jan``dec` 1st of next occurrence of that month
`1st``31st` Next occurrence of that day-of-month
`sow` / `eow` Monday / Sunday of current week
`som` / `eom` First / last day of current month
`soq` / `eoq` First / last day of current quarter
`soy` / `eoy` January 1 / December 31 of current year
`later` / `someday` Sentinel date (default: `9999-12-30`)
Time suffix: ~ *pending-dates-time*
Any named date or absolute date accepts an `@` time suffix. Supported
formats: `HH:MM` (24h), `H:MM`, bare hour (`9`, `14`), and am/pm
(`2pm`, `9:30am`, `12am`). All forms are normalized to `HH:MM` on save. >
due:tomorrow@2pm " tomorrow at 14:00
due:fri@9 " next Friday at 09:00
due:+1w@17:00 " one week from today at 17:00
due:tomorrow@9:30am " tomorrow at 09:30
due:2026-03-15@08:00 " absolute date with time
due:2026-03-15T14:30 " ISO 8601 datetime (also accepted)
<
Tasks with a time component are not considered overdue until after the
specified time. The time is displayed alongside the date in virtual text
and preserved across recurrence advances.
==============================================================================
RECURRENCE *pending-recurrence*
Tasks can recur on a schedule. Add a `rec:` token to set recurrence: >
- [ ] Take out trash due:monday rec:weekly
- [ ] Pay rent due:2026-03-01 rec:monthly
- [ ] Standup due:tomorrow rec:weekdays
<
When a recurring task is marked done with `<CR>`:
1. The current task stays as done (preserving history).
2. A new pending task is created with the same description, category,
priority, and recurrence — with the due date advanced to the next
occurrence.
Shorthand patterns: ~
Pattern Meaning ~
------- -------
`daily` Every day
`weekdays` Monday through Friday
`weekly` Every week
`biweekly` Every 2 weeks (alias: `2w`)
`monthly` Every month
`quarterly` Every 3 months (alias: `3m`)
`yearly` Every year (alias: `annual`)
`Nd` Every N days (e.g. `3d`)
`Nw` Every N weeks (e.g. `2w`)
`Nm` Every N months (e.g. `6m`)
`Ny` Every N years (e.g. `2y`)
For patterns the shorthand cannot express, use a raw RRULE fragment: >
rec:FREQ=MONTHLY;BYDAY=1MO
<
Completion-based recurrence: ~ *pending-recur-completion*
By default, recurrence is schedule-based: the next due date advances from the
original schedule, skipping to the next future occurrence. Prefix the pattern
with `!` for completion-based mode, where the next due date advances from the
completion date: >
rec:!weekly
<
Schedule-based is like org-mode `++`; completion-based is like `.+`.
Google Calendar: ~
Recurrence patterns map directly to iCalendar RRULE strings for future GCal
sync support. Completion-based recurrence cannot be synced (it is inherently
local).
============================================================================== ==============================================================================
COMMANDS *pending-commands* COMMANDS *pending-commands*
@ -267,7 +135,6 @@ COMMANDS *pending-commands*
:Pending add Buy groceries due:2026-03-15 :Pending add Buy groceries due:2026-03-15
:Pending add School: Submit homework :Pending add School: Submit homework
:Pending add Errands: Pick up dry cleaning due:fri :Pending add Errands: Pick up dry cleaning due:fri
:Pending add Work: standup due:tomorrow rec:weekdays
< <
If the buffer is currently open it is re-rendered after the add. If the buffer is currently open it is re-rendered after the add.
@ -285,52 +152,16 @@ COMMANDS *pending-commands*
Open the list with |:copen| to navigate to each task's category. Open the list with |:copen| to navigate to each task's category.
*:Pending-sync* *:Pending-sync*
:Pending sync {backend} [{action}] :Pending sync
Run a sync action against a named backend. {backend} is required — bare Push pending tasks that have a due date to Google Calendar as all-day
`:Pending sync` prints a usage message. {action} defaults to `sync` events. Requires |pending-gcal| to be configured. See |pending-gcal| for
when omitted. Each backend lives at `lua/pending/sync/<name>.lua`. full details on what gets created, updated, and deleted.
Examples: >vim
:Pending sync gcal " runs gcal.sync()
:Pending sync gcal auth " runs gcal.auth()
:Pending sync gcal sync " explicit sync (same as bare)
<
Tab completion after `:Pending sync ` lists discovered backends.
Tab completion after `:Pending sync gcal ` lists available actions.
Built-in backends: ~
`gcal` Google Calendar one-way push. See |pending-gcal|.
*:Pending-edit*
:Pending edit {id} [{operations}]
Edit metadata on an existing task without opening the buffer. {id} is the
numeric task ID. One or more operations follow: >vim
:Pending edit 5 due:tomorrow cat:Work +!
:Pending edit 5 -due -cat -rec
:Pending edit 5 rec:!weekly due:fri
:Pending edit 5 -file
<
Operations: ~
`due:<date>` Set due date (accepts all |pending-dates| vocabulary).
`cat:<name>` Set category.
`rec:<pattern>` Set recurrence (prefix `!` for completion-based).
`+!` Add priority flag.
`-!` Remove priority flag.
`-due` Clear due date.
`-cat` Clear category.
`-rec` Clear recurrence.
`-file` Clear the attached file reference (see |pending-file-token|).
Tab completion is available for IDs, field names, date values, categories,
and recurrence patterns.
*:Pending-undo* *:Pending-undo*
:Pending undo :Pending undo
Undo the last `:w` save, restoring the task store to its previous state. Undo the last `:w` save, restoring the task store to its previous state.
Equivalent to the `U` buffer-local key (see |pending-mappings|). Up to 20 Equivalent to the `U` buffer-local key (see |pending-mappings|). Up to 20
levels of undo are persisted across sessions. levels of undo are retained per session.
============================================================================== ==============================================================================
MAPPINGS *pending-mappings* MAPPINGS *pending-mappings*
@ -338,63 +169,27 @@ MAPPINGS *pending-mappings*
The following keys are set buffer-locally when the task buffer opens. They The following keys are set buffer-locally when the task buffer opens. They
are active only in the `pending://` buffer. are active only in the `pending://` buffer.
Buffer-local keys are configured via the `keymaps` table in |pending-config|. Buffer-local keys: ~
The defaults are shown below. Set any key to `false` to disable it.
Default buffer-local keys: ~
Key Action ~ Key Action ~
------- ------------------------------------------------ ------- ------------------------------------------------
`q` Close the task buffer (`close`) `<CR>` Toggle complete / uncomplete the task at cursor
`<CR>` Toggle complete / uncomplete (`toggle`) `!` Toggle the priority flag on the task at cursor
`!` Toggle the priority flag (`priority`) `D` Prompt for a due date on the task at cursor
`D` Prompt for a due date (`date`) `<Tab>` Switch between category view and priority view
`<Tab>` Switch between category / queue view (`view`) `U` Undo the last `:w` save
`U` Undo the last `:w` save (`undo`) `g?` Show a help popup with available keys
`o` Insert a new task line below (`open_line`)
`O` Insert a new task line above (`open_line_above`)
`gf` Open the file attached to the task under the cursor (`goto_file`)
`zc` Fold the current category section (category view only) `zc` Fold the current category section (category view only)
`zo` Unfold the current category section (category view only) `zo` Unfold the current category section (category view only)
Text objects (operator-pending and visual): ~ `o` and `O` are overridden to insert a correctly-formatted blank task line
at the position below or above the cursor rather than using standard Vim
Key Action ~ indentation. `dd`, `p`, `P`, and `:w` work as expected.
------- ------------------------------------------------
`at` Select the current task line (`a_task`)
`it` Select the task description only (`i_task`)
`aC` Select a category: header + tasks + blanks (`a_category`)
`iC` Select inner category: tasks only (`i_category`)
`at` supports count: `d3at` deletes three consecutive tasks. `it` selects
the description text between the checkbox prefix and trailing metadata
tokens (`due:`, `cat:`, `rec:`), making `cit` the natural way to retype a
task description without touching its metadata.
`aC` and `iC` are no-ops in the queue view (no headers to delimit).
Motions (normal, visual, operator-pending): ~
Key Action ~
------- ------------------------------------------------
`]]` Jump to the next category header (`next_header`)
`[[` Jump to the previous category header (`prev_header`)
`]t` Jump to the next task line (`next_task`)
`[t` Jump to the previous task line (`prev_task`)
All motions support count: `3]]` jumps three headers forward. `]]` and
`[[` are no-ops in the queue view. `]t` and `[t` work in both views.
`dd`, `p`, `P`, and `:w` work as standard Vim operations.
*<Plug>(pending-open)* *<Plug>(pending-open)*
<Plug>(pending-open) <Plug>(pending-open)
Open the task buffer. Maps to |:Pending| with no arguments. Open the task buffer. Maps to |:Pending| with no arguments.
*<Plug>(pending-close)*
<Plug>(pending-close)
Close the task buffer window.
*<Plug>(pending-toggle)* *<Plug>(pending-toggle)*
<Plug>(pending-toggle) <Plug>(pending-toggle)
Toggle complete / uncomplete for the task under the cursor. Toggle complete / uncomplete for the task under the cursor.
@ -411,65 +206,6 @@ All motions support count: `3]]` jumps three headers forward. `]]` and
<Plug>(pending-view) <Plug>(pending-view)
Switch between category view and priority view. Switch between category view and priority view.
*<Plug>(pending-undo)*
<Plug>(pending-undo)
Undo the last `:w` save.
*<Plug>(pending-open-line)*
<Plug>(pending-open-line)
Insert a correctly-formatted blank task line below the cursor.
*<Plug>(pending-open-line-above)*
<Plug>(pending-open-line-above)
Insert a correctly-formatted blank task line above the cursor.
*<Plug>(pending-a-task)*
<Plug>(pending-a-task)
Select the current task line (linewise). Supports count.
*<Plug>(pending-i-task)*
<Plug>(pending-i-task)
Select the task description text (characterwise).
*<Plug>(pending-a-category)*
<Plug>(pending-a-category)
Select a full category section: header, tasks, and surrounding blanks.
*<Plug>(pending-i-category)*
<Plug>(pending-i-category)
Select tasks within a category, excluding the header and blanks.
*<Plug>(pending-next-header)*
<Plug>(pending-next-header)
Jump to the next category header. Supports count.
*<Plug>(pending-prev-header)*
<Plug>(pending-prev-header)
Jump to the previous category header. Supports count.
*<Plug>(pending-next-task)*
<Plug>(pending-next-task)
Jump to the next task line, skipping headers and blanks.
*<Plug>(pending-prev-task)*
<Plug>(pending-prev-task)
Jump to the previous task line, skipping headers and blanks.
*<Plug>(pending-goto-file)*
<Plug>(pending-goto-file)
Open the file attached to the task under the cursor. If the cursor is not
on a task line, or the task has no file reference, a warning is shown. If
the referenced file cannot be read, an error is shown.
See |pending-file-token|.
*<Plug>(pending-add-here)*
<Plug>(pending-add-here)
Attach the current file and cursor line to an existing task. Invoke from
any source file (not the pending buffer itself) to open a picker listing
all active tasks. The selected task receives a `file:` reference pointing
to the current buffer's file and the cursor's line number.
See |pending-file-token|.
Example configuration: >lua Example configuration: >lua
vim.keymap.set('n', '<leader>t', '<Plug>(pending-open)') vim.keymap.set('n', '<leader>t', '<Plug>(pending-open)')
vim.keymap.set('n', '<leader>T', '<Plug>(pending-toggle)') vim.keymap.set('n', '<leader>T', '<Plug>(pending-toggle)')
@ -488,12 +224,12 @@ Category view (default): ~ *pending-view-category*
first within each group. Category sections are foldable with `zc` and first within each group. Category sections are foldable with `zc` and
`zo`. `zo`.
Queue view: ~ *pending-view-queue* Priority view: ~ *pending-view-priority*
A flat list of all tasks sorted by priority, then by due date (tasks A flat list of all tasks sorted by priority, then by due date (tasks
without a due date sort last), then by internal order. Done tasks appear without a due date sort last), then by internal order. Done tasks appear
after all pending tasks. Category names are shown as right-aligned virtual after all pending tasks. Category names are shown as right-aligned virtual
text alongside the due date virtual text so tasks remain identifiable text alongside the due date virtual text so tasks remain identifiable
across categories. The buffer is named `pending://queue`. across categories.
============================================================================== ==============================================================================
CONFIGURATION *pending-config* CONFIGURATION *pending-config*
@ -506,33 +242,10 @@ loads: >lua
default_category = 'Inbox', default_category = 'Inbox',
date_format = '%b %d', date_format = '%b %d',
date_syntax = 'due', date_syntax = 'due',
recur_syntax = 'rec',
someday_date = '9999-12-30',
category_order = {}, category_order = {},
keymaps = { gcal = {
close = 'q', calendar = 'Tasks',
toggle = '<CR>', credentials_path = '/path/to/client_secret.json',
view = '<Tab>',
priority = '!',
date = 'D',
undo = 'U',
open_line = 'o',
open_line_above = 'O',
a_task = 'at',
i_task = 'it',
a_category = 'aC',
i_category = 'iC',
next_header = ']]',
prev_header = '[[',
next_task = ']t',
prev_task = '[t',
goto_file = 'gf',
},
sync = {
gcal = {
calendar = 'Tasks',
credentials_path = '/path/to/client_secret.json',
},
}, },
} }
< <
@ -565,164 +278,16 @@ Fields: ~
this to use a different keyword, for example `'by'` this to use a different keyword, for example `'by'`
to write `by:2026-03-15` instead of `due:2026-03-15`. to write `by:2026-03-15` instead of `due:2026-03-15`.
{recur_syntax} (string, default: 'rec')
The token name for inline recurrence metadata. Change
this to use a different keyword, for example
`'repeat'` to write `repeat:weekly`.
{someday_date} (string, default: '9999-12-30')
The date that `later` and `someday` resolve to. This
acts as a "no date" sentinel for GTD-style workflows.
{category_order} (string[], default: {}) {category_order} (string[], default: {})
Ordered list of category names. In category view, Ordered list of category names. In category view,
categories that appear in this list are shown in the categories that appear in this list are shown in the
given order. Categories not in the list are appended given order. Categories not in the list are appended
after the ordered ones in their natural order. after the ordered ones in their natural order.
{keymaps} (table, default: see below) *pending.Keymaps*
Buffer-local key bindings. Each field maps an action
name to a key string. Set a field to `false` to
disable that binding. Unset fields use the default.
See |pending-mappings| for the full list of actions
and their default keys.
{goto_file} (string|false, default: 'gf')
Open the file attached to the task under the
cursor. Set to `false` to disable. See
|pending-file-token|.
{debug} (boolean, default: false)
Enable diagnostic logging. When `true`, textobj
motions, mapping registration, and cursor jumps
emit messages at `vim.log.levels.DEBUG`. Use
|:messages| to inspect the output. Useful for
diagnosing keymap conflicts (e.g. `]t` colliding
with Neovim defaults) or motion misbehavior.
Example: >lua
vim.g.pending = { debug = true }
<
{sync} (table, default: {}) *pending.SyncConfig*
Sync backend configuration. Each key is a backend
name and the value is the backend-specific config
table. Currently only `gcal` is built-in.
{gcal} (table, default: nil) {gcal} (table, default: nil)
Legacy shorthand for `sync.gcal`. If `gcal` is set Google Calendar sync configuration. See
but `sync.gcal` is not, the value is migrated |pending.GcalConfig|. Omit this field entirely to
automatically. New configs should use `sync.gcal` disable Google Calendar sync.
instead. See |pending.GcalConfig|.
==============================================================================
LUA API *pending-api*
The following functions are available on `require('pending')` for use in
statuslines, autocmds, and other integrations.
*pending.counts()*
pending.counts()
Returns a table of current task counts: >lua
{
overdue = 2, -- pending tasks past their due date/time
today = 1, -- pending tasks due today (not yet overdue)
pending = 10, -- total pending tasks (all statuses)
priority = 3, -- pending tasks with priority > 0
next_due = "2026-03-01", -- earliest future due date, or nil
}
<
The counts are read from a module-local cache that is invalidated on every
`:w`, toggle, date change, archive, undo, and sync. The first call triggers
a lazy `store.load()` if the store has not been loaded yet.
Done, deleted, and `someday` sentinel-dated tasks are excluded from the
`overdue` and `today` counts. The `someday` sentinel is the value of
`someday_date` in |pending-config| (default `9999-12-30`).
*pending.statusline()*
pending.statusline()
Returns a pre-formatted string suitable for embedding in a statusline:
- `"2 overdue, 1 today"` when both overdue and today counts are non-zero
- `"2 overdue"` when only overdue
- `"1 today"` when only today
- `""` (empty string) when nothing is actionable
*pending.has_due()*
pending.has_due()
Returns `true` when `overdue > 0` or `today > 0`. Useful as a conditional
for statusline components that should only render when tasks need attention.
*PendingStatusChanged*
PendingStatusChanged
A |User| autocmd event fired after every count recomputation. Use this to
trigger statusline refreshes or notifications: >lua
vim.api.nvim_create_autocmd('User', {
pattern = 'PendingStatusChanged',
callback = function()
vim.cmd.redrawstatus()
end,
})
<
==============================================================================
RECIPES *pending-recipes*
Configure blink.cmp to use pending.nvim's omnifunc as a completion source: >lua
require('blink.cmp').setup({
sources = {
per_filetype = {
pending = { 'omni', 'buffer' },
},
},
})
<
Lualine integration: >lua
require('lualine').setup({
sections = {
lualine_x = {
{
function() return require('pending').statusline() end,
cond = function() return require('pending').has_due() end,
},
},
},
})
<
Heirline integration: >lua
local Pending = {
condition = function() return require('pending').has_due() end,
provider = function() return require('pending').statusline() end,
}
<
Manual statusline: >vim
set statusline+=%{%v:lua.require('pending').statusline()%}
<
Startup notification: >lua
vim.api.nvim_create_autocmd('User', {
pattern = 'PendingStatusChanged',
once = true,
callback = function()
local c = require('pending').counts()
if c.overdue > 0 then
vim.notify(c.overdue .. ' overdue task(s)')
end
end,
})
<
Event-driven statusline refresh: >lua
vim.api.nvim_create_autocmd('User', {
pattern = 'PendingStatusChanged',
callback = function()
vim.cmd.redrawstatus()
end,
})
<
============================================================================== ==============================================================================
GOOGLE CALENDAR *pending-gcal* GOOGLE CALENDAR *pending-gcal*
@ -733,18 +298,13 @@ not pulled back into pending.nvim.
Configuration: >lua Configuration: >lua
vim.g.pending = { vim.g.pending = {
sync = { gcal = {
gcal = { calendar = 'Tasks',
calendar = 'Tasks', credentials_path = '/path/to/client_secret.json',
credentials_path = '/path/to/client_secret.json',
},
}, },
} }
< <
The legacy `gcal` top-level key is still accepted and migrated automatically.
New configurations should use `sync.gcal`.
*pending.GcalConfig* *pending.GcalConfig*
Fields: ~ Fields: ~
{calendar} (string, default: 'Pendings') {calendar} (string, default: 'Pendings')
@ -760,7 +320,7 @@ Fields: ~
that Google provides or as a bare credentials object. that Google provides or as a bare credentials object.
OAuth flow: ~ OAuth flow: ~
On the first `:Pending sync gcal` call the plugin detects that no refresh token On the first `:Pending sync` call the plugin detects that no refresh token
exists and opens the Google authorization URL in the browser using exists and opens the Google authorization URL in the browser using
|vim.ui.open()|. A temporary local HTTP server listens on port 18392 for the |vim.ui.open()|. A temporary local HTTP server listens on port 18392 for the
OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used — OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used —
@ -770,7 +330,7 @@ authorization code is exchanged for tokens and the refresh token is stored at
use the stored refresh token and refresh the access token automatically when use the stored refresh token and refresh the access token automatically when
it is about to expire. it is about to expire.
`:Pending sync gcal` behavior: ~ `:Pending sync` behavior: ~
For each task in the store: For each task in the store:
- A pending task with a due date and no existing event: a new all-day event is - A pending task with a due date and no existing event: a new all-day event is
created and the event ID is stored in the task's `_extra` table. created and the event ID is stored in the task's `_extra` table.
@ -783,30 +343,6 @@ For each task in the store:
A summary notification is shown after sync: `created: N, updated: N, A summary notification is shown after sync: `created: N, updated: N,
deleted: N`. deleted: N`.
==============================================================================
SYNC BACKENDS *pending-sync-backend*
Sync backends are Lua modules under `lua/pending/sync/<name>.lua`. Each
module returns a table conforming to the backend interface: >lua
---@class pending.SyncBackend
---@field name string
---@field auth fun(): nil
---@field sync fun(): nil
---@field health? fun(): nil
<
Required fields: ~
{name} Backend identifier (matches the filename).
{sync} Main sync action. Called by `:Pending sync <name>`.
{auth} Authorization flow. Called by `:Pending sync <name> auth`.
Optional fields: ~
{health} Called by `:checkhealth pending` to report backend-specific
diagnostics (e.g. checking for external tools).
Backend-specific configuration goes under `sync.<name>` in |pending-config|.
============================================================================== ==============================================================================
HIGHLIGHT GROUPS *pending-highlights* HIGHLIGHT GROUPS *pending-highlights*
@ -835,17 +371,6 @@ PendingDone Applied to the text of completed tasks.
PendingPriority Applied to the `! ` priority marker on priority tasks. PendingPriority Applied to the `! ` priority marker on priority tasks.
Default: links to `DiagnosticWarn`. Default: links to `DiagnosticWarn`.
*PendingRecur*
PendingRecur Applied to the recurrence indicator virtual text shown
alongside due dates for recurring tasks.
Default: links to `DiagnosticInfo`.
*PendingFile*
PendingFile Applied to the file reference virtual text shown for tasks
that have a `file:` token attached (see |pending-file-token|).
Displays the basename and line number (e.g. `auth.lua:42`).
Default: links to `Directory`.
To override a group in your colorscheme or config: >lua To override a group in your colorscheme or config: >lua
vim.api.nvim_set_hl(0, 'PendingDue', { fg = '#aaaaaa', italic = true }) vim.api.nvim_set_hl(0, 'PendingDue', { fg = '#aaaaaa', italic = true })
< <
@ -863,9 +388,8 @@ Checks performed: ~
category, date format, date syntax) category, date format, date syntax)
- Whether the data directory exists (warning if not yet created) - Whether the data directory exists (warning if not yet created)
- Whether the data file exists and can be parsed; reports total task count - Whether the data file exists and can be parsed; reports total task count
- Validates recurrence specs on stored tasks - Whether `curl` is available (required for Google Calendar sync)
- Discovers sync backends under `lua/pending/sync/` and runs each backend's - Whether `openssl` is available (required for OAuth PKCE)
`health()` function if it exists (e.g. gcal checks for `curl` and `openssl`)
============================================================================== ==============================================================================
DATA FORMAT *pending-data* DATA FORMAT *pending-data*
@ -890,8 +414,6 @@ Task fields: ~
{category} (string) Category name. Defaults to `default_category`. {category} (string) Category name. Defaults to `default_category`.
{priority} (integer) `1` for priority tasks, `0` otherwise. {priority} (integer) `1` for priority tasks, `0` otherwise.
{due} (string) ISO date string `YYYY-MM-DD`, or absent. {due} (string) ISO date string `YYYY-MM-DD`, or absent.
{recur} (string) Recurrence shorthand (e.g. `weekly`), or absent.
{recur_mode} (string) `'scheduled'` or `'completion'`, or absent.
{entry} (string) ISO 8601 UTC timestamp of creation. {entry} (string) ISO 8601 UTC timestamp of creation.
{modified} (string) ISO 8601 UTC timestamp of last modification. {modified} (string) ISO 8601 UTC timestamp of last modification.
{end} (string) ISO 8601 UTC timestamp of completion or deletion. {end} (string) ISO 8601 UTC timestamp of completion or deletion.

View file

@ -37,21 +37,12 @@ function M.current_view_name()
return current_view return current_view
end end
---@return nil
function M.clear_winid() function M.clear_winid()
task_winid = nil task_winid = nil
end end
---@return nil
function M.close() function M.close()
if not task_winid or not vim.api.nvim_win_is_valid(task_winid) then if task_winid and vim.api.nvim_win_is_valid(task_winid) then
task_winid = nil
return
end
local wins = vim.api.nvim_list_wins()
if #wins == 1 then
vim.cmd.enew()
else
vim.api.nvim_win_close(task_winid, false) vim.api.nvim_win_close(task_winid, false)
end end
task_winid = nil task_winid = nil
@ -64,13 +55,19 @@ local function set_buf_options(bufnr)
vim.bo[bufnr].swapfile = false vim.bo[bufnr].swapfile = false
vim.bo[bufnr].filetype = 'pending' vim.bo[bufnr].filetype = 'pending'
vim.bo[bufnr].modifiable = true vim.bo[bufnr].modifiable = true
vim.bo[bufnr].omnifunc = 'v:lua.require("pending.complete").omnifunc'
end end
---@param winid integer ---@param winid integer
local function set_win_options(winid) local function set_win_options(winid)
vim.wo[winid].conceallevel = 3 vim.wo[winid].conceallevel = 3
vim.wo[winid].concealcursor = 'nvic' 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
vim.wo[winid].winfixheight = true vim.wo[winid].winfixheight = true
end end
@ -88,7 +85,6 @@ local function setup_syntax(bufnr)
end end
---@param above boolean ---@param above boolean
---@return nil
function M.open_line(above) function M.open_line(above)
local bufnr = task_bufnr local bufnr = task_bufnr
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
@ -126,26 +122,24 @@ local function apply_extmarks(bufnr, line_meta)
local row = i - 1 local row = i - 1
if m.type == 'task' then if m.type == 'task' then
local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue' local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue'
local virt_parts = {} if m.show_category then
if m.show_category and m.category then local virt_text
table.insert(virt_parts, { m.category, 'PendingHeader' }) if m.category and m.due then
end virt_text = { { m.category .. ' ', 'PendingHeader' }, { m.due, due_hl } }
if m.recur then elseif m.category then
table.insert(virt_parts, { '\u{21bb} ' .. m.recur, 'PendingRecur' }) virt_text = { { m.category, 'PendingHeader' } }
end elseif m.due then
if m.due then virt_text = { { m.due, due_hl } }
table.insert(virt_parts, { m.due, due_hl })
end
if m.file then
local display = m.file:match('([^/]+:%d+)$') or m.file
table.insert(virt_parts, { display, 'PendingFile' })
end
if #virt_parts > 0 then
for p = 1, #virt_parts - 1 do
virt_parts[p][1] = virt_parts[p][1] .. ' '
end end
if virt_text then
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
virt_text = virt_text,
virt_text_pos = 'eol',
})
end
elseif m.due then
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
virt_text = virt_parts, virt_text = { { m.due, due_hl } },
virt_text_pos = 'eol', virt_text_pos = 'eol',
}) })
end end
@ -173,8 +167,6 @@ local function setup_highlights()
vim.api.nvim_set_hl(0, 'PendingOverdue', { link = 'DiagnosticError', default = true }) vim.api.nvim_set_hl(0, 'PendingOverdue', { link = 'DiagnosticError', default = true })
vim.api.nvim_set_hl(0, 'PendingDone', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'PendingDone', { link = 'Comment', default = true })
vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true }) vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true })
vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true })
vim.api.nvim_set_hl(0, 'PendingFile', { link = 'Directory', default = true })
end end
local function snapshot_folds(bufnr) local function snapshot_folds(bufnr)
@ -220,7 +212,6 @@ local function restore_folds(bufnr)
end end
---@param bufnr? integer ---@param bufnr? integer
---@return nil
function M.render(bufnr) function M.render(bufnr)
bufnr = bufnr or task_bufnr bufnr = bufnr or task_bufnr
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
@ -228,8 +219,7 @@ function M.render(bufnr)
end end
current_view = current_view or config.get().default_view current_view = current_view or config.get().default_view
local view_label = current_view == 'priority' and 'queue' or current_view vim.api.nvim_buf_set_name(bufnr, 'pending://' .. current_view)
vim.api.nvim_buf_set_name(bufnr, 'pending://' .. view_label)
local tasks = store.active_tasks() local tasks = store.active_tasks()
local lines, line_meta local lines, line_meta
@ -266,7 +256,6 @@ function M.render(bufnr)
restore_folds(bufnr) restore_folds(bufnr)
end end
---@return nil
function M.toggle_view() function M.toggle_view()
if current_view == 'category' then if current_view == 'category' then
current_view = 'priority' current_view = 'priority'

View file

@ -1,172 +0,0 @@
local config = require('pending.config')
---@class pending.complete
local M = {}
---@return string
local function date_key()
return config.get().date_syntax or 'due'
end
---@return string
local function recur_key()
return config.get().recur_syntax or 'rec'
end
---@return string[]
local function get_categories()
local store = require('pending.store')
local seen = {}
local result = {}
for _, task in ipairs(store.active_tasks()) do
local cat = task.category
if cat and not seen[cat] then
seen[cat] = true
table.insert(result, cat)
end
end
table.sort(result)
return result
end
---@return { word: string, info: string }[]
local function date_completions()
return {
{ word = 'today', info = "Today's date" },
{ word = 'tomorrow', info = "Tomorrow's date" },
{ word = 'yesterday', info = "Yesterday's date" },
{ word = '+1d', info = '1 day from today' },
{ word = '+2d', info = '2 days from today' },
{ word = '+3d', info = '3 days from today' },
{ word = '+1w', info = '1 week from today' },
{ word = '+2w', info = '2 weeks from today' },
{ word = '+1m', info = '1 month from today' },
{ word = 'mon', info = 'Next Monday' },
{ word = 'tue', info = 'Next Tuesday' },
{ word = 'wed', info = 'Next Wednesday' },
{ word = 'thu', info = 'Next Thursday' },
{ word = 'fri', info = 'Next Friday' },
{ word = 'sat', info = 'Next Saturday' },
{ word = 'sun', info = 'Next Sunday' },
{ word = 'eod', info = 'End of day (today)' },
{ word = 'eow', info = 'End of week (Sunday)' },
{ word = 'eom', info = 'End of month' },
{ word = 'eoq', info = 'End of quarter' },
{ word = 'eoy', info = 'End of year (Dec 31)' },
{ word = 'sow', info = 'Start of week (Monday)' },
{ word = 'som', info = 'Start of month' },
{ word = 'soq', info = 'Start of quarter' },
{ word = 'soy', info = 'Start of year (Jan 1)' },
{ word = 'later', info = 'Someday (sentinel date)' },
{ word = 'today@08:00', info = 'Today at 08:00' },
{ word = 'today@09:00', info = 'Today at 09:00' },
{ word = 'today@10:00', info = 'Today at 10:00' },
{ word = 'today@12:00', info = 'Today at 12:00' },
{ word = 'today@14:00', info = 'Today at 14:00' },
{ word = 'today@17:00', info = 'Today at 17:00' },
}
end
---@type table<string, string>
local recur_descriptions = {
daily = 'Every day',
weekdays = 'Monday through Friday',
weekly = 'Every week',
biweekly = 'Every 2 weeks',
monthly = 'Every month',
quarterly = 'Every 3 months',
yearly = 'Every year',
['2d'] = 'Every 2 days',
['3d'] = 'Every 3 days',
['2w'] = 'Every 2 weeks',
['3w'] = 'Every 3 weeks',
['2m'] = 'Every 2 months',
['3m'] = 'Every 3 months',
['6m'] = 'Every 6 months',
['2y'] = 'Every 2 years',
}
---@return { word: string, info: string }[]
local function recur_completions()
local recur = require('pending.recur')
local list = recur.shorthand_list()
local result = {}
for _, s in ipairs(list) do
local desc = recur_descriptions[s] or s
table.insert(result, { word = s, info = desc })
end
for _, s in ipairs(list) do
local desc = recur_descriptions[s] or s
table.insert(result, { word = '!' .. s, info = desc .. ' (from completion date)' })
end
return result
end
---@type string?
local _complete_source = nil
---@param findstart integer
---@param base string
---@return integer|table[]
function M.omnifunc(findstart, base)
if findstart == 1 then
local line = vim.api.nvim_get_current_line()
local col = vim.api.nvim_win_get_cursor(0)[2]
local before = line:sub(1, col)
local dk = date_key()
local rk = recur_key()
local checks = {
{ vim.pesc(dk) .. ':([%S]*)$', dk },
{ 'cat:([%S]*)$', 'cat' },
{ vim.pesc(rk) .. ':([%S]*)$', rk },
{ 'file:([%S]*)$', 'file' },
}
for _, check in ipairs(checks) do
local start = before:find(check[1])
if start then
local colon_pos = before:find(':', start, true)
if colon_pos then
_complete_source = check[2]
return colon_pos
end
end
end
_complete_source = nil
return -1
end
local matches = {}
local source = _complete_source or ''
local dk = date_key()
local rk = recur_key()
if source == dk then
for _, c in ipairs(date_completions()) do
if base == '' or c.word:sub(1, #base) == base then
table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info })
end
end
elseif source == 'cat' then
for _, c in ipairs(get_categories()) do
if base == '' or c:sub(1, #base) == base then
table.insert(matches, { word = c, menu = '[cat]' })
end
end
elseif source == rk then
for _, c in ipairs(recur_completions()) do
if base == '' or c.word:sub(1, #base) == base then
table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info })
end
end
elseif source == 'file' then
end
return matches
end
return M

View file

@ -2,41 +2,14 @@
---@field calendar? string ---@field calendar? string
---@field credentials_path? string ---@field credentials_path? string
---@class pending.SyncConfig
---@field gcal? pending.GcalConfig
---@class pending.Keymaps
---@field close? string|false
---@field toggle? string|false
---@field view? string|false
---@field priority? string|false
---@field date? string|false
---@field undo? string|false
---@field open_line? string|false
---@field open_line_above? string|false
---@field a_task? string|false
---@field i_task? string|false
---@field a_category? string|false
---@field i_category? string|false
---@field next_header? string|false
---@field prev_header? string|false
---@field next_task? string|false
---@field prev_task? string|false
---@field goto_file? string|false
---@class pending.Config ---@class pending.Config
---@field data_path string ---@field data_path string
---@field default_view 'category'|'priority' ---@field default_view 'category'|'priority'
---@field default_category string ---@field default_category string
---@field date_format string ---@field date_format string
---@field date_syntax string ---@field date_syntax string
---@field recur_syntax string
---@field someday_date string
---@field category_order? string[] ---@field category_order? string[]
---@field drawer_height? integer ---@field drawer_height? integer
---@field debug? boolean
---@field keymaps pending.Keymaps
---@field sync? pending.SyncConfig
---@field gcal? pending.GcalConfig ---@field gcal? pending.GcalConfig
---@class pending.config ---@class pending.config
@ -49,28 +22,7 @@ local defaults = {
default_category = 'Todo', default_category = 'Todo',
date_format = '%b %d', date_format = '%b %d',
date_syntax = 'due', date_syntax = 'due',
recur_syntax = 'rec',
someday_date = '9999-12-30',
category_order = {}, category_order = {},
keymaps = {
close = 'q',
toggle = '<CR>',
view = '<Tab>',
priority = '!',
date = 'D',
undo = 'U',
open_line = 'o',
open_line_above = 'O',
a_task = 'at',
i_task = 'it',
a_category = 'aC',
i_category = 'iC',
next_header = ']]',
prev_header = '[[',
next_task = ']t',
prev_task = '[t',
},
sync = {},
} }
---@type pending.Config? ---@type pending.Config?
@ -83,14 +35,9 @@ function M.get()
end end
local user = vim.g.pending or {} local user = vim.g.pending or {}
_resolved = vim.tbl_deep_extend('force', defaults, user) _resolved = vim.tbl_deep_extend('force', defaults, user)
if _resolved.gcal and not (_resolved.sync and _resolved.sync.gcal) then
_resolved.sync = _resolved.sync or {}
_resolved.sync.gcal = _resolved.gcal
end
return _resolved return _resolved
end end
---@return nil
function M.reset() function M.reset()
_resolved = nil _resolved = nil
end end

View file

@ -10,9 +10,6 @@ local store = require('pending.store')
---@field status? string ---@field status? string
---@field category? string ---@field category? string
---@field due? string ---@field due? string
---@field rec? string
---@field rec_mode? string
---@field file? string
---@field lnum integer ---@field lnum integer
---@class pending.diff ---@class pending.diff
@ -51,9 +48,6 @@ function M.parse_buffer(lines)
status = status, status = status,
category = metadata.cat or current_category or config.get().default_category, category = metadata.cat or current_category or config.get().default_category,
due = metadata.due, due = metadata.due,
rec = metadata.rec,
rec_mode = metadata.rec_mode,
file = metadata.file,
lnum = i, lnum = i,
}) })
end end
@ -67,9 +61,7 @@ function M.parse_buffer(lines)
end end
---@param lines string[] ---@param lines string[]
---@param hidden_ids? table<integer, boolean> function M.apply(lines)
---@return nil
function M.apply(lines, hidden_ids)
local parsed = M.parse_buffer(lines) local parsed = M.parse_buffer(lines)
local now = timestamp() local now = timestamp()
local data = store.data() local data = store.data()
@ -98,8 +90,6 @@ function M.apply(lines, hidden_ids)
category = entry.category, category = entry.category,
priority = entry.priority, priority = entry.priority,
due = entry.due, due = entry.due,
recur = entry.rec,
recur_mode = entry.rec_mode,
order = order_counter, order = order_counter,
}) })
else else
@ -122,27 +112,6 @@ function M.apply(lines, hidden_ids)
task.due = entry.due task.due = entry.due
changed = true changed = true
end end
if task.recur ~= entry.rec then
task.recur = entry.rec
changed = true
end
if task.recur_mode ~= entry.rec_mode then
task.recur_mode = entry.rec_mode
changed = true
end
local old_file = (task._extra and task._extra.file) or nil
if entry.file ~= old_file then
task._extra = task._extra or {}
if entry.file then
task._extra.file = entry.file
else
task._extra.file = nil
if next(task._extra) == nil then
task._extra = nil
end
end
changed = true
end
if entry.status and task.status ~= entry.status then if entry.status and task.status ~= entry.status then
task.status = entry.status task.status = entry.status
if entry.status == 'done' then if entry.status == 'done' then
@ -166,8 +135,6 @@ function M.apply(lines, hidden_ids)
category = entry.category, category = entry.category,
priority = entry.priority, priority = entry.priority,
due = entry.due, due = entry.due,
recur = entry.rec,
recur_mode = entry.rec_mode,
order = order_counter, order = order_counter,
}) })
end end

View file

@ -1,6 +1,5 @@
local M = {} local M = {}
---@return nil
function M.check() function M.check()
vim.health.start('pending.nvim') vim.health.start('pending.nvim')
@ -28,17 +27,6 @@ function M.check()
if load_ok then if load_ok then
local tasks = store.tasks() local tasks = store.tasks()
vim.health.ok('Data file loaded: ' .. #tasks .. ' tasks') vim.health.ok('Data file loaded: ' .. #tasks .. ' tasks')
local recur = require('pending.recur')
local invalid_count = 0
for _, task in ipairs(tasks) do
if task.recur and not recur.validate(task.recur) then
invalid_count = invalid_count + 1
vim.health.warn('Task ' .. task.id .. ' has invalid recurrence spec: ' .. task.recur)
end
end
if invalid_count == 0 then
vim.health.ok('All recurrence specs are valid')
end
else else
vim.health.error('Failed to load data file: ' .. tostring(err)) vim.health.error('Failed to load data file: ' .. tostring(err))
end end
@ -47,18 +35,16 @@ function M.check()
vim.health.info('No data file yet (will be created on first save)') vim.health.info('No data file yet (will be created on first save)')
end end
local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true) if vim.fn.executable('curl') == 1 then
if #sync_paths == 0 then vim.health.ok('curl found (required for Google Calendar sync)')
vim.health.info('No sync backends found')
else else
for _, path in ipairs(sync_paths) do vim.health.warn('curl not found (needed for Google Calendar sync)')
local name = vim.fn.fnamemodify(path, ':t:r') end
local bok, backend = pcall(require, 'pending.sync.' .. name)
if bok and type(backend.health) == 'function' then if vim.fn.executable('openssl') == 1 then
vim.health.start('pending.nvim: sync/' .. name) vim.health.ok('openssl found (required for OAuth PKCE)')
backend.health() else
end vim.health.warn('openssl not found (needed for Google Calendar OAuth)')
end
end end
end end

View file

@ -1,100 +1,15 @@
local buffer = require('pending.buffer') local buffer = require('pending.buffer')
local config = require('pending.config')
local diff = require('pending.diff') local diff = require('pending.diff')
local parse = require('pending.parse') local parse = require('pending.parse')
local store = require('pending.store') local store = require('pending.store')
---@class pending.Counts
---@field overdue integer
---@field today integer
---@field pending integer
---@field priority integer
---@field next_due? string
---@class pending.init ---@class pending.init
local M = {} local M = {}
---@type pending.Task[][]
local _undo_states = {}
local UNDO_MAX = 20 local UNDO_MAX = 20
---@type pending.Counts?
local _counts = nil
---@return nil
function M._recompute_counts()
local cfg = require('pending.config').get()
local someday = cfg.someday_date
local overdue = 0
local today = 0
local pending = 0
local priority = 0
local next_due = nil ---@type string?
local today_str = os.date('%Y-%m-%d') --[[@as string]]
for _, task in ipairs(store.active_tasks()) do
if task.status == 'pending' then
pending = pending + 1
if task.priority > 0 then
priority = priority + 1
end
if task.due and task.due ~= someday then
if parse.is_overdue(task.due) then
overdue = overdue + 1
elseif parse.is_today(task.due) then
today = today + 1
end
local date_part = task.due:match('^(%d%d%d%d%-%d%d%-%d%d)') or task.due
if date_part >= today_str and (not next_due or task.due < next_due) then
next_due = task.due
end
end
end
end
_counts = {
overdue = overdue,
today = today,
pending = pending,
priority = priority,
next_due = next_due,
}
vim.api.nvim_exec_autocmds('User', { pattern = 'PendingStatusChanged' })
end
---@return nil
local function _save_and_notify()
store.save()
M._recompute_counts()
end
---@return pending.Counts
function M.counts()
if not _counts then
store.load()
M._recompute_counts()
end
return _counts --[[@as pending.Counts]]
end
---@return string
function M.statusline()
local c = M.counts()
if c.overdue > 0 and c.today > 0 then
return c.overdue .. ' overdue, ' .. c.today .. ' today'
elseif c.overdue > 0 then
return c.overdue .. ' overdue'
elseif c.today > 0 then
return c.today .. ' today'
end
return ''
end
---@return boolean
function M.has_due()
local c = M.counts()
return c.overdue > 0 or c.today > 0
end
---@return integer bufnr ---@return integer bufnr
function M.open() function M.open()
local bufnr = buffer.open() local bufnr = buffer.open()
@ -104,7 +19,6 @@ function M.open()
end end
---@param bufnr integer ---@param bufnr integer
---@return nil
function M._setup_autocmds(bufnr) function M._setup_autocmds(bufnr)
local group = vim.api.nvim_create_augroup('PendingBuffer', { clear = true }) local group = vim.api.nvim_create_augroup('PendingBuffer', { clear = true })
vim.api.nvim_create_autocmd('BufWriteCmd', { vim.api.nvim_create_autocmd('BufWriteCmd', {
@ -135,153 +49,63 @@ function M._setup_autocmds(bufnr)
end end
---@param bufnr integer ---@param bufnr integer
---@return nil
function M._setup_buf_mappings(bufnr) function M._setup_buf_mappings(bufnr)
local cfg = require('pending.config').get()
local km = cfg.keymaps
local opts = { buffer = bufnr, silent = true } local opts = { buffer = bufnr, silent = true }
vim.keymap.set('n', 'q', function()
---@type table<string, fun()> buffer.close()
local actions = { end, opts)
close = function() vim.keymap.set('n', '<Esc>', function()
buffer.close() buffer.close()
end, end, opts)
toggle = function() vim.keymap.set('n', '<CR>', function()
M.toggle_complete() M.toggle_complete()
end, end, opts)
view = function() vim.keymap.set('n', '<Tab>', function()
buffer.toggle_view() buffer.toggle_view()
end, end, opts)
priority = function() vim.keymap.set('n', 'g?', function()
M.toggle_priority() M.show_help()
end, end, opts)
date = function() vim.keymap.set('n', '!', function()
M.prompt_date() M.toggle_priority()
end, end, opts)
undo = function() vim.keymap.set('n', 'D', function()
M.undo_write() M.prompt_date()
end, end, opts)
open_line = function() vim.keymap.set('n', 'U', function()
buffer.open_line(false) M.undo_write()
end, end, opts)
open_line_above = function() vim.keymap.set('n', 'o', function()
buffer.open_line(true) buffer.open_line(false)
end, end, opts)
} vim.keymap.set('n', 'O', function()
buffer.open_line(true)
for name, fn in pairs(actions) do end, opts)
local key = km[name]
if key and key ~= false then
vim.keymap.set('n', key --[[@as string]], fn, opts)
end
end
local textobj = require('pending.textobj')
---@type table<string, { modes: string[], fn: fun(count: integer), visual_fn?: fun(count: integer) }>
local textobjs = {
a_task = {
modes = { 'o', 'x' },
fn = textobj.a_task,
visual_fn = textobj.a_task_visual,
},
i_task = {
modes = { 'o', 'x' },
fn = textobj.i_task,
visual_fn = textobj.i_task_visual,
},
a_category = {
modes = { 'o', 'x' },
fn = textobj.a_category,
visual_fn = textobj.a_category_visual,
},
i_category = {
modes = { 'o', 'x' },
fn = textobj.i_category,
visual_fn = textobj.i_category_visual,
},
}
for name, spec in pairs(textobjs) do
local key = km[name]
if key and key ~= false then
for _, mode in ipairs(spec.modes) do
if mode == 'x' and spec.visual_fn then
vim.keymap.set(mode, key --[[@as string]], function()
spec.visual_fn(vim.v.count1)
end, opts)
else
vim.keymap.set(mode, key --[[@as string]], function()
spec.fn(vim.v.count1)
end, opts)
end
end
end
end
---@type table<string, fun(count: integer)>
local motions = {
next_header = textobj.next_header,
prev_header = textobj.prev_header,
next_task = textobj.next_task,
prev_task = textobj.prev_task,
}
for name, fn in pairs(motions) do
local key = km[name]
if cfg.debug then
vim.notify(
('[pending] mapping motion %s → %s (buf=%d)'):format(name, key or 'nil', bufnr),
vim.log.levels.INFO
)
end
if key and key ~= false then
vim.keymap.set({ 'n', 'x', 'o' }, key --[[@as string]], function()
fn(vim.v.count1)
end, opts)
end
end
local goto_key = km.goto_file
if goto_key == nil then
goto_key = 'gf'
end
if goto_key and goto_key ~= false then
vim.keymap.set('n', goto_key --[[@as string]], function()
M.goto_file()
end, opts)
end
end end
---@param bufnr integer ---@param bufnr integer
---@return nil
function M._on_write(bufnr) function M._on_write(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local snapshot = store.snapshot() local snapshot = store.snapshot()
local stack = store.undo_stack() table.insert(_undo_states, snapshot)
table.insert(stack, snapshot) if #_undo_states > UNDO_MAX then
if #stack > UNDO_MAX then table.remove(_undo_states, 1)
table.remove(stack, 1)
end end
diff.apply(lines) diff.apply(lines)
M._recompute_counts()
buffer.render(bufnr) buffer.render(bufnr)
end end
---@return nil
function M.undo_write() function M.undo_write()
local stack = store.undo_stack() if #_undo_states == 0 then
if #stack == 0 then
vim.notify('Nothing to undo.', vim.log.levels.WARN) vim.notify('Nothing to undo.', vim.log.levels.WARN)
return return
end end
local state = table.remove(stack) local state = table.remove(_undo_states)
store.replace_tasks(state) store.replace_tasks(state)
_save_and_notify() store.save()
buffer.render(buffer.bufnr()) buffer.render(buffer.bufnr())
end end
---@return nil
function M.toggle_complete() function M.toggle_complete()
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
if not bufnr then if not bufnr then
@ -303,22 +127,9 @@ function M.toggle_complete()
if task.status == 'done' then if task.status == 'done' then
store.update(id, { status = 'pending', ['end'] = vim.NIL }) store.update(id, { status = 'pending', ['end'] = vim.NIL })
else else
if task.recur and task.due then
local recur = require('pending.recur')
local mode = task.recur_mode or 'scheduled'
local next_date = recur.next_due(task.due, task.recur, mode)
store.add({
description = task.description,
category = task.category,
priority = task.priority,
due = next_date,
recur = task.recur,
recur_mode = task.recur_mode,
})
end
store.update(id, { status = 'done' }) store.update(id, { status = 'done' })
end end
_save_and_notify() store.save()
buffer.render(bufnr) buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do for lnum, m in ipairs(buffer.meta()) do
if m.id == id then if m.id == id then
@ -328,7 +139,6 @@ function M.toggle_complete()
end end
end end
---@return nil
function M.toggle_priority() function M.toggle_priority()
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
if not bufnr then if not bufnr then
@ -349,7 +159,7 @@ function M.toggle_priority()
end end
local new_priority = task.priority > 0 and 0 or 1 local new_priority = task.priority > 0 and 0 or 1
store.update(id, { priority = new_priority }) store.update(id, { priority = new_priority })
_save_and_notify() store.save()
buffer.render(bufnr) buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do for lnum, m in ipairs(buffer.meta()) do
if m.id == id then if m.id == id then
@ -359,7 +169,6 @@ function M.toggle_priority()
end end
end end
---@return nil
function M.prompt_date() function M.prompt_date()
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
if not bufnr then if not bufnr then
@ -374,7 +183,7 @@ function M.prompt_date()
if not id then if not id then
return return
end end
vim.ui.input({ prompt = 'Due date (today, +3d, fri@2pm, etc.): ' }, function(input) vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input)
if not input then if not input then
return return
end end
@ -383,22 +192,18 @@ function M.prompt_date()
local resolved = parse.resolve_date(due) local resolved = parse.resolve_date(due)
if resolved then if resolved then
due = resolved due = resolved
elseif elseif not due:match('^%d%d%d%d%-%d%d%-%d%d$') then
not due:match('^%d%d%d%d%-%d%d%-%d%d$') vim.notify('Invalid date format. Use YYYY-MM-DD.', vim.log.levels.ERROR)
and not due:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$')
then
vim.notify('Invalid date format. Use YYYY-MM-DD or YYYY-MM-DDThh:mm.', vim.log.levels.ERROR)
return return
end end
end end
store.update(id, { due = due }) store.update(id, { due = due })
_save_and_notify() store.save()
buffer.render(bufnr) buffer.render(bufnr)
end) end)
end end
---@param text string ---@param text string
---@return nil
function M.add(text) function M.add(text)
if not text or text == '' then if not text or text == '' then
vim.notify('Usage: :Pending add <description>', vim.log.levels.ERROR) vim.notify('Usage: :Pending add <description>', vim.log.levels.ERROR)
@ -414,10 +219,8 @@ function M.add(text)
description = description, description = description,
category = metadata.cat, category = metadata.cat,
due = metadata.due, due = metadata.due,
recur = metadata.rec,
recur_mode = metadata.rec_mode,
}) })
_save_and_notify() store.save()
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr) buffer.render(bufnr)
@ -425,29 +228,16 @@ function M.add(text)
vim.notify('Pending added: ' .. description) vim.notify('Pending added: ' .. description)
end end
---@param backend_name string function M.sync()
---@param action? string local ok, gcal = pcall(require, 'pending.sync.gcal')
---@return nil
function M.sync(backend_name, action)
if not backend_name or backend_name == '' then
vim.notify('Usage: :Pending sync <backend> [action]', vim.log.levels.ERROR)
return
end
action = (action and action ~= '') and action or 'sync'
local ok, backend = pcall(require, 'pending.sync.' .. backend_name)
if not ok then if not ok then
vim.notify('Unknown sync backend: ' .. backend_name, vim.log.levels.ERROR) vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR)
return return
end end
if type(backend[action]) ~= 'function' then gcal.sync()
vim.notify(backend_name .. " backend has no '" .. action .. "' action", vim.log.levels.ERROR)
return
end
backend[action]()
end end
---@param days? integer ---@param days? integer
---@return nil
function M.archive(days) function M.archive(days)
days = days or 30 days = days or 30
local cutoff = os.time() - (days * 86400) local cutoff = os.time() - (days * 86400)
@ -476,7 +266,7 @@ function M.archive(days)
::skip:: ::skip::
end end
store.replace_tasks(kept) store.replace_tasks(kept)
_save_and_notify() store.save()
vim.notify('Archived ' .. archived .. ' tasks.') vim.notify('Archived ' .. archived .. ' tasks.')
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
@ -484,8 +274,8 @@ function M.archive(days)
end end
end end
---@return nil
function M.due() function M.due()
local today = os.date('%Y-%m-%d') --[[@as string]]
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
local is_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr) local is_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr)
local meta = is_valid and buffer.meta() or nil local meta = is_valid and buffer.meta() or nil
@ -493,14 +283,9 @@ function M.due()
if meta and bufnr then if meta and bufnr then
for lnum, m in ipairs(meta) do for lnum, m in ipairs(meta) do
if if m.type == 'task' and m.raw_due and m.status ~= 'done' and m.raw_due <= today then
m.type == 'task'
and m.raw_due
and m.status ~= 'done'
and (parse.is_overdue(m.raw_due) or parse.is_today(m.raw_due))
then
local task = store.get(m.id or 0) local task = store.get(m.id or 0)
local label = parse.is_overdue(m.raw_due) and '[OVERDUE] ' or '[DUE] ' local label = m.raw_due < today and '[OVERDUE] ' or '[DUE] '
table.insert(qf_items, { table.insert(qf_items, {
bufnr = bufnr, bufnr = bufnr,
lnum = lnum, lnum = lnum,
@ -512,12 +297,8 @@ function M.due()
else else
store.load() store.load()
for _, task in ipairs(store.active_tasks()) do for _, task in ipairs(store.active_tasks()) do
if if task.status == 'pending' and task.due and task.due <= today then
task.status == 'pending' local label = task.due < today and '[OVERDUE] ' or '[DUE] '
and task.due
and (parse.is_overdue(task.due) or parse.is_today(task.due))
then
local label = parse.is_overdue(task.due) and '[OVERDUE] ' or '[DUE] '
local text = label .. task.description local text = label .. task.description
if task.category then if task.category then
text = text .. ' [' .. task.category .. ']' text = text .. ' [' .. task.category .. ']'
@ -536,283 +317,68 @@ function M.due()
vim.cmd('copen') vim.cmd('copen')
end end
---@param token string function M.show_help()
---@return string|nil field
---@return any value
---@return string|nil err
local function parse_edit_token(token)
local recur = require('pending.recur')
local cfg = require('pending.config').get() local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due' local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec' local lines = {
'pending.nvim keybindings',
if token == '+!' then '',
return 'priority', 1, nil '<CR> Toggle complete/uncomplete',
end '<Tab> Switch category/priority view',
if token == '-!' then '! Toggle urgent',
return 'priority', 0, nil 'D Set due date',
end 'U Undo last write',
if token == '-due' or token == '-' .. dk then 'o / O Add new task line',
return 'due', vim.NIL, nil 'dd Delete task line (on :w)',
end 'p / P Paste (duplicates get new IDs)',
if token == '-cat' then 'zc / zo Fold/unfold category (category view)',
return 'category', vim.NIL, nil ':w Save all changes',
end '',
if token == '-rec' or token == '-' .. rk then ':Pending add <text> Quick-add task',
return 'recur', vim.NIL, nil ':Pending add Cat: <text> Quick-add with category',
end ':Pending due Show overdue/due qflist',
if token == '-file' then ':Pending sync Push to Google Calendar',
return 'file_clear', true, nil ':Pending archive [days] Purge old done tasks',
end ':Pending undo Undo last write',
'',
local due_val = token:match('^' .. vim.pesc(dk) .. ':(.+)$') 'Inline metadata (on new lines before :w):',
if due_val then ' ' .. dk .. ':YYYY-MM-DD Set due date',
local resolved = parse.resolve_date(due_val) ' cat:Name Set category',
if resolved then '',
return 'due', resolved, nil 'Due date input:',
end ' today, tomorrow, +Nd, mon-sun',
if ' Empty input clears due date',
due_val:match('^%d%d%d%d%-%d%d%-%d%d$') or due_val:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$') '',
then 'Highlights:',
return 'due', due_val, nil ' PendingOverdue overdue tasks (red)',
end ' PendingPriority [!] urgent tasks',
return nil, '',
nil, 'Press q or <Esc> to close',
'Invalid date: ' .. due_val .. '. Use YYYY-MM-DD, today, tomorrow, +Nd, weekday names, etc.' }
end local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
local cat_val = token:match('^cat:(.+)$') vim.bo[buf].modifiable = false
if cat_val then vim.bo[buf].bufhidden = 'wipe'
return 'category', cat_val, nil local width = 54
end local height = #lines
local win = vim.api.nvim_open_win(buf, true, {
local rec_val = token:match('^' .. vim.pesc(rk) .. ':(.+)$') relative = 'editor',
if rec_val then width = width,
local raw_spec = rec_val height = height,
local rec_mode = nil col = math.floor((vim.o.columns - width) / 2),
if raw_spec:sub(1, 1) == '!' then row = math.floor((vim.o.lines - height) / 2),
rec_mode = 'completion' style = 'minimal',
raw_spec = raw_spec:sub(2) border = 'rounded',
end })
if not recur.validate(raw_spec) then vim.keymap.set('n', 'q', function()
return nil, nil, 'Invalid recurrence pattern: ' .. rec_val vim.api.nvim_win_close(win, true)
end end, { buffer = buf, silent = true })
return 'recur', { spec = raw_spec, mode = rec_mode }, nil vim.keymap.set('n', '<Esc>', function()
end vim.api.nvim_win_close(win, true)
end, { buffer = buf, silent = true })
return nil,
nil,
'Unknown operation: '
.. token
.. '. Valid: '
.. dk
.. ':<date>, cat:<name>, '
.. rk
.. ':<pattern>, file:<path>:<line>, +!, -!, -'
.. dk
.. ', -cat, -'
.. rk
.. ', -file'
end
---@param id_str string
---@param rest string
---@return nil
function M.edit(id_str, rest)
if not id_str or id_str == '' then
vim.notify(
'Usage: :Pending edit <id> [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-due] [-cat] [-rec]',
vim.log.levels.ERROR
)
return
end
local id = tonumber(id_str)
if not id then
vim.notify('Invalid task ID: ' .. id_str, vim.log.levels.ERROR)
return
end
store.load()
local task = store.get(id)
if not task then
vim.notify('No task with ID ' .. id .. '.', vim.log.levels.ERROR)
return
end
if not rest or rest == '' then
vim.notify(
'Usage: :Pending edit <id> [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-due] [-cat] [-rec]',
vim.log.levels.ERROR
)
return
end
local tokens = {}
for tok in rest:gmatch('%S+') do
table.insert(tokens, tok)
end
local updates = {}
local feedback = {}
for _, tok in ipairs(tokens) do
local field, value, err = parse_edit_token(tok)
if err then
vim.notify(err, vim.log.levels.ERROR)
return
end
if field == 'recur' then
if value == vim.NIL then
updates.recur = vim.NIL
updates.recur_mode = vim.NIL
table.insert(feedback, 'recurrence removed')
else
updates.recur = value.spec
updates.recur_mode = value.mode
table.insert(feedback, 'recurrence set to ' .. value.spec)
end
elseif field == 'due' then
if value == vim.NIL then
updates.due = vim.NIL
table.insert(feedback, 'due date removed')
else
updates.due = value
table.insert(feedback, 'due date set to ' .. tostring(value))
end
elseif field == 'category' then
if value == vim.NIL then
updates.category = vim.NIL
table.insert(feedback, 'category removed')
else
updates.category = value
table.insert(feedback, 'category set to ' .. tostring(value))
end
elseif field == 'priority' then
updates.priority = value
table.insert(feedback, value == 1 and 'priority added' or 'priority removed')
elseif field == 'file_clear' then
updates.file_clear = true
table.insert(feedback, 'file reference removed')
end
end
local snapshot = store.snapshot()
local stack = store.undo_stack()
table.insert(stack, snapshot)
if #stack > UNDO_MAX then
table.remove(stack, 1)
end
store.update(id, updates)
if updates.file_clear then
local t = store.get(id)
if t and t._extra then
t._extra.file = nil
if next(t._extra) == nil then
t._extra = nil
end
t.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
end
end
store.save()
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
end
vim.notify('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', '))
end
---@return nil
function M.goto_file()
local bufnr = vim.api.nvim_get_current_buf()
if vim.bo[bufnr].filetype ~= 'pending' then
return
end
local lnum = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
local m = meta and meta[lnum]
if not m or m.type ~= 'task' then
vim.notify('No task on this line', vim.log.levels.WARN)
return
end
local task = store.get(m.id)
if not task or not task._extra or not task._extra.file then
vim.notify('No file attached to this task', vim.log.levels.WARN)
return
end
local file_spec = task._extra.file
local rel_path, line_str = file_spec:match('^(.+):(%d+)$')
if not rel_path then
vim.notify('Invalid file spec: ' .. file_spec, vim.log.levels.ERROR)
return
end
local data_dir = vim.fn.fnamemodify(config.get().data_path, ':h')
local abs_path = data_dir .. '/' .. rel_path
if vim.fn.filereadable(abs_path) == 0 then
vim.notify('File not found: ' .. abs_path, vim.log.levels.ERROR)
return
end
vim.cmd.edit(abs_path)
local lnum_target = tonumber(line_str) or 1
vim.api.nvim_win_set_cursor(0, { lnum_target, 0 })
end
---@return nil
function M.add_here()
local cur_bufnr = vim.api.nvim_get_current_buf()
if vim.bo[cur_bufnr].filetype == 'pending' then
vim.notify('Already in pending buffer', vim.log.levels.WARN)
return
end
local cur_file = vim.api.nvim_buf_get_name(cur_bufnr)
if cur_file == '' or vim.fn.filereadable(cur_file) == 0 then
vim.notify('Not editing a readable file', vim.log.levels.ERROR)
return
end
local cur_lnum = vim.api.nvim_win_get_cursor(0)[1]
local data_dir = vim.fn.fnamemodify(config.get().data_path, ':h')
local abs_file = vim.fn.fnamemodify(cur_file, ':p')
local rel_file
if abs_file:sub(1, #data_dir + 1) == data_dir .. '/' then
rel_file = abs_file:sub(#data_dir + 2)
else
rel_file = abs_file
end
local file_spec = rel_file .. ':' .. cur_lnum
store.load()
local tasks = store.active_tasks()
if #tasks == 0 then
vim.notify('No active tasks', vim.log.levels.INFO)
return
end
local items = {}
for _, task in ipairs(tasks) do
table.insert(items, task)
end
vim.ui.select(items, {
prompt = 'Attach file to task:',
format_item = function(task)
return '[' .. task.id .. '] ' .. task.description
end,
}, function(task)
if not task then
return
end
task._extra = task._extra or {}
task._extra.file = file_spec
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
store.save()
vim.notify('Attached ' .. file_spec .. ' to task ' .. task.id)
end)
end end
---@param args string ---@param args string
---@return nil
function M.command(args) function M.command(args)
if not args or args == '' then if not args or args == '' then
M.open() M.open()
@ -821,12 +387,8 @@ function M.command(args)
local cmd, rest = args:match('^(%S+)%s*(.*)') local cmd, rest = args:match('^(%S+)%s*(.*)')
if cmd == 'add' then if cmd == 'add' then
M.add(rest) M.add(rest)
elseif cmd == 'edit' then
local id_str, edit_rest = rest:match('^(%S+)%s*(.*)')
M.edit(id_str, edit_rest)
elseif cmd == 'sync' then elseif cmd == 'sync' then
local backend, action = rest:match('^(%S+)%s*(.*)') M.sync()
M.sync(backend, action)
elseif cmd == 'archive' then elseif cmd == 'archive' then
local d = rest ~= '' and tonumber(rest) or nil local d = rest ~= '' and tonumber(rest) or nil
M.archive(d) M.archive(d)

View file

@ -24,92 +24,11 @@ local function is_valid_date(s)
return check.year == yn and check.month == mn and check.day == dn return check.year == yn and check.month == mn and check.day == dn
end end
---@param s string
---@return boolean
local function is_valid_time(s)
local h, m = s:match('^(%d%d):(%d%d)$')
if not h then
return false
end
local hn = tonumber(h) --[[@as integer]]
local mn = tonumber(m) --[[@as integer]]
return hn >= 0 and hn <= 23 and mn >= 0 and mn <= 59
end
---@param s string
---@return string|nil
local function normalize_time(s)
local h, m, period
h, m, period = s:match('^(%d+):(%d%d)([ap]m)$')
if not h then
h, period = s:match('^(%d+)([ap]m)$')
if h then
m = '00'
end
end
if not h then
h, m = s:match('^(%d%d):(%d%d)$')
end
if not h then
h, m = s:match('^(%d):(%d%d)$')
end
if not h then
h = s:match('^(%d+)$')
if h then
m = '00'
end
end
if not h then
return nil
end
local hn = tonumber(h) --[[@as integer]]
local mn = tonumber(m) --[[@as integer]]
if period then
if hn < 1 or hn > 12 then
return nil
end
if period == 'am' then
hn = hn == 12 and 0 or hn
else
hn = hn == 12 and 12 or hn + 12
end
else
if hn < 0 or hn > 23 then
return nil
end
end
if mn < 0 or mn > 59 then
return nil
end
return string.format('%02d:%02d', hn, mn)
end
---@param s string
---@return boolean
local function is_valid_datetime(s)
local date_part, time_part = s:match('^(.+)T(.+)$')
if not date_part then
return is_valid_date(s)
end
return is_valid_date(date_part) and is_valid_time(time_part)
end
---@return string ---@return string
local function date_key() local function date_key()
return config.get().date_syntax or 'due' return config.get().date_syntax or 'due'
end end
---@return string
local function recur_key()
return config.get().recur_syntax or 'rec'
end
local weekday_map = { local weekday_map = {
sun = 1, sun = 1,
mon = 2, mon = 2,
@ -120,295 +39,45 @@ local weekday_map = {
sat = 7, sat = 7,
} }
local month_map = {
jan = 1,
feb = 2,
mar = 3,
apr = 4,
may = 5,
jun = 6,
jul = 7,
aug = 8,
sep = 9,
oct = 10,
nov = 11,
dec = 12,
}
---@param today osdate
---@return string
local function today_str(today)
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]]
end
---@param date_part string
---@param time_suffix? string
---@return string
local function append_time(date_part, time_suffix)
if time_suffix then
return date_part .. 'T' .. time_suffix
end
return date_part
end
---@param text string ---@param text string
---@return string|nil ---@return string|nil
function M.resolve_date(text) function M.resolve_date(text)
local date_input, time_suffix = text:match('^(.+)@(.+)$') local lower = text:lower()
if time_suffix then
time_suffix = normalize_time(time_suffix)
if not time_suffix then
return nil
end
else
date_input = text
end
local dt = date_input:match('^(%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d)$')
if dt then
local dp, tp = dt:match('^(.+)T(.+)$')
if is_valid_date(dp) and is_valid_time(tp) then
return dt
end
return nil
end
if is_valid_date(date_input) then
return append_time(date_input, time_suffix)
end
local lower = date_input:lower()
local today = os.date('*t') --[[@as osdate]] local today = os.date('*t') --[[@as osdate]]
if lower == 'today' or lower == 'eod' then if lower == 'today' then
return append_time(today_str(today), time_suffix) return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]]
end
if lower == 'yesterday' then
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day - 1 })) --[[@as string]],
time_suffix
)
end end
if lower == 'tomorrow' then if lower == 'tomorrow' then
return append_time( return os.date(
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) --[[@as string]], '%Y-%m-%d',
time_suffix os.time({ year = today.year, month = today.month, day = today.day + 1 })
) ) --[[@as string]]
end
if lower == 'sow' then
local delta = -((today.wday - 2) % 7)
return append_time(
os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + delta })
) --[[@as string]],
time_suffix
)
end
if lower == 'eow' then
local delta = (1 - today.wday) % 7
return append_time(
os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + delta })
) --[[@as string]],
time_suffix
)
end
if lower == 'som' then
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = 1 })) --[[@as string]],
time_suffix
)
end
if lower == 'eom' then
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 })) --[[@as string]],
time_suffix
)
end
if lower == 'soq' then
local q = math.ceil(today.month / 3)
local first_month = (q - 1) * 3 + 1
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = first_month, day = 1 })) --[[@as string]],
time_suffix
)
end
if lower == 'eoq' then
local q = math.ceil(today.month / 3)
local last_month = q * 3
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 })) --[[@as string]],
time_suffix
)
end
if lower == 'soy' then
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = 1, day = 1 })) --[[@as string]],
time_suffix
)
end
if lower == 'eoy' then
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = 12, day = 31 })) --[[@as string]],
time_suffix
)
end
if lower == 'later' or lower == 'someday' then
return append_time(config.get().someday_date, time_suffix)
end end
local n = lower:match('^%+(%d+)d$') local n = lower:match('^%+(%d+)d$')
if n then if n then
return append_time( return os.date(
os.date( '%Y-%m-%d',
'%Y-%m-%d', os.time({
os.time({ year = today.year,
year = today.year, month = today.month,
month = today.month, day = today.day + (
day = today.day + ( tonumber(n) --[[@as integer]]
tonumber(n) --[[@as integer]] ),
), })
}) ) --[[@as string]]
) --[[@as string]],
time_suffix
)
end
n = lower:match('^%+(%d+)w$')
if n then
return append_time(
os.date(
'%Y-%m-%d',
os.time({
year = today.year,
month = today.month,
day = today.day + (
tonumber(n) --[[@as integer]]
) * 7,
})
) --[[@as string]],
time_suffix
)
end
n = lower:match('^%+(%d+)m$')
if n then
return append_time(
os.date(
'%Y-%m-%d',
os.time({
year = today.year,
month = today.month + (
tonumber(n) --[[@as integer]]
),
day = today.day,
})
) --[[@as string]],
time_suffix
)
end
n = lower:match('^%-(%d+)d$')
if n then
return append_time(
os.date(
'%Y-%m-%d',
os.time({
year = today.year,
month = today.month,
day = today.day - (
tonumber(n) --[[@as integer]]
),
})
) --[[@as string]],
time_suffix
)
end
n = lower:match('^%-(%d+)w$')
if n then
return append_time(
os.date(
'%Y-%m-%d',
os.time({
year = today.year,
month = today.month,
day = today.day - (
tonumber(n) --[[@as integer]]
) * 7,
})
) --[[@as string]],
time_suffix
)
end
local ord = lower:match('^(%d+)[snrt][tdh]$')
if ord then
local day_num = tonumber(ord) --[[@as integer]]
if day_num >= 1 and day_num <= 31 then
local m, y = today.month, today.year
if today.day >= day_num then
m = m + 1
if m > 12 then
m = 1
y = y + 1
end
end
local t = os.time({ year = y, month = m, day = day_num })
local check = os.date('*t', t) --[[@as osdate]]
if check.day == day_num then
return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
end
m = m + 1
if m > 12 then
m = 1
y = y + 1
end
t = os.time({ year = y, month = m, day = day_num })
check = os.date('*t', t) --[[@as osdate]]
if check.day == day_num then
return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
end
return nil
end
end
local target_month = month_map[lower]
if target_month then
local y = today.year
if today.month >= target_month then
y = y + 1
end
return append_time(
os.date('%Y-%m-%d', os.time({ year = y, month = target_month, day = 1 })) --[[@as string]],
time_suffix
)
end end
local target_wday = weekday_map[lower] local target_wday = weekday_map[lower]
if target_wday then if target_wday then
local current_wday = today.wday local current_wday = today.wday
local delta = (target_wday - current_wday) % 7 local delta = (target_wday - current_wday) % 7
return append_time( return os.date(
os.date( '%Y-%m-%d',
'%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + delta })
os.time({ year = today.year, month = today.month, day = today.day + delta }) ) --[[@as string]]
) --[[@as string]],
time_suffix
)
end end
return nil return nil
@ -416,7 +85,7 @@ end
---@param text string ---@param text string
---@return string description ---@return string description
---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion', file?: string } metadata ---@return { due?: string, cat?: string } metadata
function M.body(text) function M.body(text)
local tokens = {} local tokens = {}
for token in text:gmatch('%S+') do for token in text:gmatch('%S+') do
@ -426,10 +95,8 @@ function M.body(text)
local metadata = {} local metadata = {}
local i = #tokens local i = #tokens
local dk = date_key() local dk = date_key()
local rk = recur_key() local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$'
local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d[T%d:]*)$'
local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$' local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$'
local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$'
while i >= 1 do while i >= 1 do
local token = tokens[i] local token = tokens[i]
@ -438,7 +105,7 @@ function M.body(text)
if metadata.due then if metadata.due then
break break
end end
if not is_valid_datetime(due_val) then if not is_valid_date(due_val) then
break break
end end
metadata.due = due_val metadata.due = due_val
@ -464,36 +131,7 @@ function M.body(text)
metadata.cat = cat_val metadata.cat = cat_val
i = i - 1 i = i - 1
else else
local rec_val = token:match(rec_pattern) break
if rec_val then
if metadata.rec then
break
end
local recur = require('pending.recur')
local raw_spec = rec_val
if raw_spec:sub(1, 1) == '!' then
metadata.rec_mode = 'completion'
raw_spec = raw_spec:sub(2)
end
if not recur.validate(raw_spec) then
break
end
metadata.rec = raw_spec
i = i - 1
else
local file_path_val, file_line_val = token:match('^file:(.+):(%d+)$')
if file_path_val and file_line_val then
if metadata.file then
break
end
metadata.file = file_path_val .. ':' .. file_line_val
i = i - 1
elseif token:match('^file:') then
break
else
break
end
end
end end
end end
end end
@ -510,7 +148,7 @@ end
---@param text string ---@param text string
---@return string description ---@return string description
---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion', file?: string } metadata ---@return { due?: string, cat?: string } metadata
function M.command_add(text) function M.command_add(text)
local cat_prefix = text:match('^(%S.-):%s') local cat_prefix = text:match('^(%S.-):%s')
if cat_prefix then if cat_prefix then
@ -527,39 +165,4 @@ function M.command_add(text)
return M.body(text) return M.body(text)
end end
---@param due string
---@return boolean
function M.is_overdue(due)
local now = os.date('*t') --[[@as osdate]]
local today = os.date('%Y-%m-%d') --[[@as string]]
local date_part, time_part = due:match('^(.+)T(.+)$')
if not date_part then
return due < today
end
if date_part < today then
return true
end
if date_part > today then
return false
end
local current_time = string.format('%02d:%02d', now.hour, now.min)
return time_part < current_time
end
---@param due string
---@return boolean
function M.is_today(due)
local now = os.date('*t') --[[@as osdate]]
local today = os.date('%Y-%m-%d') --[[@as string]]
local date_part, time_part = due:match('^(.+)T(.+)$')
if not date_part then
return due == today
end
if date_part ~= today then
return false
end
local current_time = string.format('%02d:%02d', now.hour, now.min)
return time_part >= current_time
end
return M return M

View file

@ -1,188 +0,0 @@
---@class pending.RecurSpec
---@field freq 'daily'|'weekly'|'monthly'|'yearly'
---@field interval integer
---@field byday? string[]
---@field from_completion boolean
---@field _raw? string
---@class pending.recur
local M = {}
---@type table<string, pending.RecurSpec>
local named = {
daily = { freq = 'daily', interval = 1, from_completion = false },
weekdays = {
freq = 'weekly',
interval = 1,
byday = { 'MO', 'TU', 'WE', 'TH', 'FR' },
from_completion = false,
},
weekly = { freq = 'weekly', interval = 1, from_completion = false },
biweekly = { freq = 'weekly', interval = 2, from_completion = false },
monthly = { freq = 'monthly', interval = 1, from_completion = false },
quarterly = { freq = 'monthly', interval = 3, from_completion = false },
yearly = { freq = 'yearly', interval = 1, from_completion = false },
annual = { freq = 'yearly', interval = 1, from_completion = false },
}
---@param spec string
---@return pending.RecurSpec?
function M.parse(spec)
local from_completion = false
local s = spec
if s:sub(1, 1) == '!' then
from_completion = true
s = s:sub(2)
end
local lower = s:lower()
local base = named[lower]
if base then
return {
freq = base.freq,
interval = base.interval,
byday = base.byday,
from_completion = from_completion,
}
end
local n, unit = lower:match('^(%d+)([dwmy])$')
if n then
local num = tonumber(n) --[[@as integer]]
if num < 1 then
return nil
end
local freq_map = { d = 'daily', w = 'weekly', m = 'monthly', y = 'yearly' }
return {
freq = freq_map[unit],
interval = num,
from_completion = from_completion,
}
end
if s:match('^FREQ=') then
return {
freq = 'daily',
interval = 1,
from_completion = from_completion,
_raw = s,
}
end
return nil
end
---@param spec string
---@return boolean
function M.validate(spec)
return M.parse(spec) ~= nil
end
---@param due string
---@return string date_part
---@return string? time_part
local function split_datetime(due)
local dp, tp = due:match('^(.+)T(.+)$')
if dp then
return dp, tp
end
return due, nil
end
---@param base_date string
---@param freq string
---@param interval integer
---@return string
local function advance_date(base_date, freq, interval)
local date_part, time_part = split_datetime(base_date)
local y, m, d = date_part:match('^(%d+)-(%d+)-(%d+)$')
local yn = tonumber(y) --[[@as integer]]
local mn = tonumber(m) --[[@as integer]]
local dn = tonumber(d) --[[@as integer]]
local result
if freq == 'daily' then
result = os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval })) --[[@as string]]
elseif freq == 'weekly' then
result = os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval * 7 })) --[[@as string]]
elseif freq == 'monthly' then
local new_m = mn + interval
local new_y = yn
while new_m > 12 do
new_m = new_m - 12
new_y = new_y + 1
end
local last_day = os.date('*t', os.time({ year = new_y, month = new_m + 1, day = 0 })) --[[@as osdate]]
local clamped_d = math.min(dn, last_day.day --[[@as integer]])
result = os.date('%Y-%m-%d', os.time({ year = new_y, month = new_m, day = clamped_d })) --[[@as string]]
elseif freq == 'yearly' then
local new_y = yn + interval
local last_day = os.date('*t', os.time({ year = new_y, month = mn + 1, day = 0 })) --[[@as osdate]]
local clamped_d = math.min(dn, last_day.day --[[@as integer]])
result = os.date('%Y-%m-%d', os.time({ year = new_y, month = mn, day = clamped_d })) --[[@as string]]
else
return base_date
end
if time_part then
return result .. 'T' .. time_part
end
return result
end
---@param base_date string
---@param spec string
---@param mode 'scheduled'|'completion'
---@return string
function M.next_due(base_date, spec, mode)
local parsed = M.parse(spec)
if not parsed then
return base_date
end
local today = os.date('%Y-%m-%d') --[[@as string]]
local _, time_part = split_datetime(base_date)
if mode == 'completion' then
local base = time_part and (today .. 'T' .. time_part) or today
return advance_date(base, parsed.freq, parsed.interval)
end
local next_date = advance_date(base_date, parsed.freq, parsed.interval)
local compare_today = time_part and (today .. 'T' .. time_part) or today
while next_date <= compare_today do
next_date = advance_date(next_date, parsed.freq, parsed.interval)
end
return next_date
end
---@param spec string
---@return string
function M.to_rrule(spec)
local parsed = M.parse(spec)
if not parsed then
return ''
end
if parsed._raw then
return 'RRULE:' .. parsed._raw
end
local parts = { 'FREQ=' .. parsed.freq:upper() }
if parsed.interval > 1 then
table.insert(parts, 'INTERVAL=' .. parsed.interval)
end
if parsed.byday then
table.insert(parts, 'BYDAY=' .. table.concat(parsed.byday, ','))
end
return 'RRULE:' .. table.concat(parts, ';')
end
---@return string[]
function M.shorthand_list()
return { 'daily', 'weekdays', 'weekly', 'biweekly', 'monthly', 'quarterly', 'yearly', 'annual' }
end
return M

View file

@ -7,8 +7,6 @@ local config = require('pending.config')
---@field category? string ---@field category? string
---@field priority integer ---@field priority integer
---@field due? string ---@field due? string
---@field recur? string
---@field recur_mode? 'scheduled'|'completion'
---@field entry string ---@field entry string
---@field modified string ---@field modified string
---@field end? string ---@field end? string
@ -19,7 +17,6 @@ local config = require('pending.config')
---@field version integer ---@field version integer
---@field next_id integer ---@field next_id integer
---@field tasks pending.Task[] ---@field tasks pending.Task[]
---@field undo pending.Task[][]
---@class pending.store ---@class pending.store
local M = {} local M = {}
@ -35,7 +32,6 @@ local function empty_data()
version = SUPPORTED_VERSION, version = SUPPORTED_VERSION,
next_id = 1, next_id = 1,
tasks = {}, tasks = {},
undo = {},
} }
end end
@ -60,8 +56,6 @@ local known_fields = {
category = true, category = true,
priority = true, priority = true,
due = true, due = true,
recur = true,
recur_mode = true,
entry = true, entry = true,
modified = true, modified = true,
['end'] = true, ['end'] = true,
@ -87,12 +81,6 @@ local function task_to_table(task)
if task.due then if task.due then
t.due = task.due t.due = task.due
end end
if task.recur then
t.recur = task.recur
end
if task.recur_mode then
t.recur_mode = task.recur_mode
end
if task['end'] then if task['end'] then
t['end'] = task['end'] t['end'] = task['end']
end end
@ -117,8 +105,6 @@ local function table_to_task(t)
category = t.category, category = t.category,
priority = t.priority or 0, priority = t.priority or 0,
due = t.due, due = t.due,
recur = t.recur,
recur_mode = t.recur_mode,
entry = t.entry, entry = t.entry,
modified = t.modified, modified = t.modified,
['end'] = t['end'], ['end'] = t['end'],
@ -167,24 +153,13 @@ function M.load()
version = decoded.version or SUPPORTED_VERSION, version = decoded.version or SUPPORTED_VERSION,
next_id = decoded.next_id or 1, next_id = decoded.next_id or 1,
tasks = {}, tasks = {},
undo = {},
} }
for _, t in ipairs(decoded.tasks or {}) do for _, t in ipairs(decoded.tasks or {}) do
table.insert(_data.tasks, table_to_task(t)) table.insert(_data.tasks, table_to_task(t))
end end
for _, snapshot in ipairs(decoded.undo or {}) do
if type(snapshot) == 'table' then
local tasks = {}
for _, raw in ipairs(snapshot) do
table.insert(tasks, table_to_task(raw))
end
table.insert(_data.undo, tasks)
end
end
return _data return _data
end end
---@return nil
function M.save() function M.save()
if not _data then if not _data then
return return
@ -195,18 +170,10 @@ function M.save()
version = _data.version, version = _data.version,
next_id = _data.next_id, next_id = _data.next_id,
tasks = {}, tasks = {},
undo = {},
} }
for _, task in ipairs(_data.tasks) do for _, task in ipairs(_data.tasks) do
table.insert(out.tasks, task_to_table(task)) table.insert(out.tasks, task_to_table(task))
end end
for _, snapshot in ipairs(_data.undo) do
local serialized = {}
for _, task in ipairs(snapshot) do
table.insert(serialized, task_to_table(task))
end
table.insert(out.undo, serialized)
end
local encoded = vim.json.encode(out) local encoded = vim.json.encode(out)
local tmp = path .. '.tmp' local tmp = path .. '.tmp'
local f = io.open(tmp, 'w') local f = io.open(tmp, 'w')
@ -257,7 +224,7 @@ function M.get(id)
return nil return nil
end end
---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, recur?: string, recur_mode?: string, order?: integer, _extra?: table } ---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, order?: integer, _extra?: table }
---@return pending.Task ---@return pending.Task
function M.add(fields) function M.add(fields)
local data = M.data() local data = M.data()
@ -269,8 +236,6 @@ function M.add(fields)
category = fields.category or config.get().default_category, category = fields.category or config.get().default_category,
priority = fields.priority or 0, priority = fields.priority or 0,
due = fields.due, due = fields.due,
recur = fields.recur,
recur_mode = fields.recur_mode,
entry = now, entry = now,
modified = now, modified = now,
['end'] = nil, ['end'] = nil,
@ -293,11 +258,7 @@ function M.update(id, fields)
local now = timestamp() local now = timestamp()
for k, v in pairs(fields) do for k, v in pairs(fields) do
if k ~= 'id' and k ~= 'entry' then if k ~= 'id' and k ~= 'entry' then
if v == vim.NIL then task[k] = v
task[k] = nil
else
task[k] = v
end
end end
end end
task.modified = now task.modified = now
@ -325,7 +286,6 @@ function M.find_index(id)
end end
---@param tasks pending.Task[] ---@param tasks pending.Task[]
---@return nil
function M.replace_tasks(tasks) function M.replace_tasks(tasks)
M.data().tasks = tasks M.data().tasks = tasks
end end
@ -351,24 +311,11 @@ function M.snapshot()
return result return result
end end
---@return pending.Task[][]
function M.undo_stack()
return M.data().undo
end
---@param stack pending.Task[][]
---@return nil
function M.set_undo_stack(stack)
M.data().undo = stack
end
---@param id integer ---@param id integer
---@return nil
function M.set_next_id(id) function M.set_next_id(id)
M.data().next_id = id M.data().next_id = id
end end
---@return nil
function M.unload() function M.unload()
_data = nil _data = nil
end end

View file

@ -3,8 +3,6 @@ local store = require('pending.store')
local M = {} local M = {}
M.name = 'gcal'
local BASE_URL = 'https://www.googleapis.com/calendar/v3' local BASE_URL = 'https://www.googleapis.com/calendar/v3'
local TOKEN_URL = 'https://oauth2.googleapis.com/token' local TOKEN_URL = 'https://oauth2.googleapis.com/token'
local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
@ -24,7 +22,7 @@ local SCOPE = 'https://www.googleapis.com/auth/calendar'
---@return table<string, any> ---@return table<string, any>
local function gcal_config() local function gcal_config()
local cfg = config.get() local cfg = config.get()
return (cfg.sync and cfg.sync.gcal) or cfg.gcal or {} return cfg.gcal or {}
end end
---@return string ---@return string
@ -201,7 +199,7 @@ local function get_access_token()
end end
local tokens = load_tokens() local tokens = load_tokens()
if not tokens or not tokens.refresh_token then if not tokens or not tokens.refresh_token then
M.auth() M.authorize()
tokens = load_tokens() tokens = load_tokens()
if not tokens then if not tokens then
return nil return nil
@ -220,7 +218,7 @@ local function get_access_token()
return tokens.access_token return tokens.access_token
end end
function M.auth() function M.authorize()
local creds = load_credentials() local creds = load_credentials()
if not creds then if not creds then
vim.notify( vim.notify(
@ -505,7 +503,6 @@ function M.sync()
end end
store.save() store.save()
require('pending')._recompute_counts()
vim.notify( vim.notify(
string.format( string.format(
'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)', 'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)',
@ -516,18 +513,4 @@ function M.sync()
) )
end end
---@return nil
function M.health()
if vim.fn.executable('curl') == 1 then
vim.health.ok('curl found (required for gcal sync)')
else
vim.health.warn('curl not found (needed for gcal sync)')
end
if vim.fn.executable('openssl') == 1 then
vim.health.ok('openssl found (required for gcal OAuth PKCE)')
else
vim.health.warn('openssl not found (needed for gcal OAuth)')
end
end
return M return M

View file

@ -1,384 +0,0 @@
local buffer = require('pending.buffer')
local config = require('pending.config')
---@class pending.textobj
local M = {}
---@param ... any
---@return nil
local function dbg(...)
if config.get().debug then
vim.notify('[pending.textobj] ' .. string.format(...), vim.log.levels.INFO)
end
end
---@param lnum integer
---@param meta pending.LineMeta[]
---@return string
local function get_line_from_buf(lnum, meta)
local _ = meta
local bufnr = buffer.bufnr()
if not bufnr then
return ''
end
local lines = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, false)
return lines[1] or ''
end
---@param line string
---@return integer start_col
---@return integer end_col
function M.inner_task_range(line)
local prefix_end = line:find('/') and select(2, line:find('^/%d+/%- %[.%] '))
if not prefix_end then
prefix_end = select(2, line:find('^%- %[.%] ')) or 0
end
local start_col = prefix_end + 1
local dk = config.get().date_syntax or 'due'
local rk = config.get().recur_syntax or 'rec'
local dk_pat = '^' .. vim.pesc(dk) .. ':%S+$'
local rk_pat = '^' .. vim.pesc(rk) .. ':%S+$'
local rest = line:sub(start_col)
local words = {}
for word in rest:gmatch('%S+') do
table.insert(words, word)
end
local i = #words
while i >= 1 do
local word = words[i]
if word:match(dk_pat) or word:match('^cat:%S+$') or word:match(rk_pat) then
i = i - 1
else
break
end
end
if i < 1 then
return start_col, start_col
end
local desc = table.concat(words, ' ', 1, i)
local end_col = start_col + #desc - 1
return start_col, end_col
end
---@param row integer
---@param meta pending.LineMeta[]
---@return integer? header_row
---@return integer? last_row
function M.category_bounds(row, meta)
if not meta or #meta == 0 then
return nil, nil
end
local header_row = nil
local m = meta[row]
if not m then
return nil, nil
end
if m.type == 'header' then
header_row = row
else
for r = row, 1, -1 do
if meta[r] and meta[r].type == 'header' then
header_row = r
break
end
end
end
if not header_row then
return nil, nil
end
local last_row = header_row
local total = #meta
for r = header_row + 1, total do
if meta[r].type == 'header' then
break
end
last_row = r
end
return header_row, last_row
end
---@param count integer
---@return nil
function M.a_task(count)
local meta = buffer.meta()
if not meta or #meta == 0 then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local m = meta[row]
if not m or m.type ~= 'task' then
return
end
local start_row = row
local end_row = row
count = math.max(1, count)
for _ = 2, count do
local next_row = end_row + 1
if next_row > #meta then
break
end
if meta[next_row] and meta[next_row].type == 'task' then
end_row = next_row
else
break
end
end
vim.cmd('normal! ' .. start_row .. 'GV' .. end_row .. 'G')
end
---@param count integer
---@return nil
function M.a_task_visual(count)
vim.cmd('normal! \27')
M.a_task(count)
end
---@param count integer
---@return nil
function M.i_task(count)
local _ = count
local meta = buffer.meta()
if not meta or #meta == 0 then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local m = meta[row]
if not m or m.type ~= 'task' then
return
end
local line = get_line_from_buf(row, meta)
local start_col, end_col = M.inner_task_range(line)
if start_col > end_col then
return
end
vim.api.nvim_win_set_cursor(0, { row, start_col - 1 })
vim.cmd('normal! v')
vim.api.nvim_win_set_cursor(0, { row, end_col - 1 })
end
---@param count integer
---@return nil
function M.i_task_visual(count)
vim.cmd('normal! \27')
M.i_task(count)
end
---@param count integer
---@return nil
function M.a_category(count)
local _ = count
local meta = buffer.meta()
if not meta or #meta == 0 then
return
end
local view = buffer.current_view_name()
if view == 'priority' then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local header_row, last_row = M.category_bounds(row, meta)
if not header_row or not last_row then
return
end
local start_row = header_row
if header_row > 1 and meta[header_row - 1] and meta[header_row - 1].type == 'blank' then
start_row = header_row - 1
end
local end_row = last_row
if last_row < #meta and meta[last_row + 1] and meta[last_row + 1].type == 'blank' then
end_row = last_row + 1
end
vim.cmd('normal! ' .. start_row .. 'GV' .. end_row .. 'G')
end
---@param count integer
---@return nil
function M.a_category_visual(count)
vim.cmd('normal! \27')
M.a_category(count)
end
---@param count integer
---@return nil
function M.i_category(count)
local _ = count
local meta = buffer.meta()
if not meta or #meta == 0 then
return
end
local view = buffer.current_view_name()
if view == 'priority' then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local header_row, last_row = M.category_bounds(row, meta)
if not header_row or not last_row then
return
end
local first_task = nil
local last_task = nil
for r = header_row + 1, last_row do
if meta[r] and meta[r].type == 'task' then
if not first_task then
first_task = r
end
last_task = r
end
end
if not first_task or not last_task then
return
end
vim.cmd('normal! ' .. first_task .. 'GV' .. last_task .. 'G')
end
---@param count integer
---@return nil
function M.i_category_visual(count)
vim.cmd('normal! \27')
M.i_category(count)
end
---@param count integer
---@return nil
function M.next_header(count)
local meta = buffer.meta()
if not meta or #meta == 0 then
return
end
local view = buffer.current_view_name()
if view == 'priority' then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
dbg('next_header: cursor=%d, meta_len=%d, view=%s', row, #meta, view or 'nil')
local found = 0
count = math.max(1, count)
for r = row + 1, #meta do
if meta[r] and meta[r].type == 'header' then
found = found + 1
dbg(
'next_header: found header at row=%d, cat=%s, found=%d/%d',
r,
meta[r].category or '?',
found,
count
)
if found == count then
vim.api.nvim_win_set_cursor(0, { r, 0 })
dbg('next_header: cursor set to row=%d, actual=%d', r, vim.api.nvim_win_get_cursor(0)[1])
return
end
else
dbg('next_header: row=%d type=%s', r, meta[r] and meta[r].type or 'nil')
end
end
dbg('next_header: no header found after row=%d', row)
end
---@param count integer
---@return nil
function M.prev_header(count)
local meta = buffer.meta()
if not meta or #meta == 0 then
return
end
local view = buffer.current_view_name()
if view == 'priority' then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
dbg('prev_header: cursor=%d, meta_len=%d', row, #meta)
local found = 0
count = math.max(1, count)
for r = row - 1, 1, -1 do
if meta[r] and meta[r].type == 'header' then
found = found + 1
dbg(
'prev_header: found header at row=%d, cat=%s, found=%d/%d',
r,
meta[r].category or '?',
found,
count
)
if found == count then
vim.api.nvim_win_set_cursor(0, { r, 0 })
return
end
end
end
end
---@param count integer
---@return nil
function M.next_task(count)
local meta = buffer.meta()
if not meta or #meta == 0 then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
dbg('next_task: cursor=%d, meta_len=%d', row, #meta)
local found = 0
count = math.max(1, count)
for r = row + 1, #meta do
if meta[r] and meta[r].type == 'task' then
found = found + 1
if found == count then
dbg('next_task: jumping to row=%d', r)
vim.api.nvim_win_set_cursor(0, { r, 0 })
return
end
end
end
dbg('next_task: no task found after row=%d', row)
end
---@param count integer
---@return nil
function M.prev_task(count)
local meta = buffer.meta()
if not meta or #meta == 0 then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
dbg('prev_task: cursor=%d, meta_len=%d', row, #meta)
local found = 0
count = math.max(1, count)
for r = row - 1, 1, -1 do
if meta[r] and meta[r].type == 'task' then
found = found + 1
if found == count then
dbg('prev_task: jumping to row=%d', r)
vim.api.nvim_win_set_cursor(0, { r, 0 })
return
end
end
end
dbg('prev_task: no task found before row=%d', row)
end
return M

View file

@ -1,5 +1,4 @@
local config = require('pending.config') local config = require('pending.config')
local parse = require('pending.parse')
---@class pending.LineMeta ---@class pending.LineMeta
---@field type 'task'|'header'|'blank' ---@field type 'task'|'header'|'blank'
@ -11,8 +10,6 @@ local parse = require('pending.parse')
---@field overdue? boolean ---@field overdue? boolean
---@field show_category? boolean ---@field show_category? boolean
---@field priority? integer ---@field priority? integer
---@field recur? string
---@field file? string
---@class pending.views ---@class pending.views
local M = {} local M = {}
@ -23,10 +20,7 @@ local function format_due(due)
if not due then if not due then
return nil return nil
end end
local y, m, d, hh, mm = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d)$') local y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
if not y then
y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
end
if not y then if not y then
return due return due
end end
@ -35,11 +29,7 @@ local function format_due(due)
month = tonumber(m) --[[@as integer]], month = tonumber(m) --[[@as integer]],
day = tonumber(d) --[[@as integer]], day = tonumber(d) --[[@as integer]],
}) })
local formatted = os.date(config.get().date_format, t) --[[@as string]] return os.date(config.get().date_format, t) --[[@as string]]
if hh then
formatted = formatted .. ' ' .. hh .. ':' .. mm
end
return formatted
end end
---@param tasks pending.Task[] ---@param tasks pending.Task[]
@ -83,6 +73,7 @@ end
---@return string[] lines ---@return string[] lines
---@return pending.LineMeta[] meta ---@return pending.LineMeta[] meta
function M.category_view(tasks) function M.category_view(tasks)
local today = os.date('%Y-%m-%d') --[[@as string]]
local by_cat = {} local by_cat = {}
local cat_order = {} local cat_order = {}
local cat_seen = {} local cat_seen = {}
@ -157,10 +148,7 @@ function M.category_view(tasks)
raw_due = task.due, raw_due = task.due,
status = task.status, status = task.status,
category = cat, category = cat,
overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil,
or nil,
recur = task.recur,
file = task._extra and task._extra.file or nil,
}) })
end end
end end
@ -172,6 +160,7 @@ end
---@return string[] lines ---@return string[] lines
---@return pending.LineMeta[] meta ---@return pending.LineMeta[] meta
function M.priority_view(tasks) function M.priority_view(tasks)
local today = os.date('%Y-%m-%d') --[[@as string]]
local pending = {} local pending = {}
local done = {} local done = {}
@ -209,10 +198,8 @@ function M.priority_view(tasks)
raw_due = task.due, raw_due = task.due,
status = task.status, status = task.status,
category = task.category, category = task.category,
overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) or nil, overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil,
show_category = true, show_category = true,
recur = task.recur,
file = task._extra and task._extra.file or nil,
}) })
end end

View file

@ -3,203 +3,16 @@ if vim.g.loaded_pending then
end end
vim.g.loaded_pending = true vim.g.loaded_pending = true
---@return string[]
local function edit_field_candidates()
local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
return {
dk .. ':',
'cat:',
rk .. ':',
'file:',
'+!',
'-!',
'-' .. dk,
'-cat',
'-' .. rk,
'-file',
}
end
---@return string[]
local function edit_date_values()
return {
'today',
'tomorrow',
'yesterday',
'+1d',
'+2d',
'+3d',
'+1w',
'+2w',
'+1m',
'mon',
'tue',
'wed',
'thu',
'fri',
'sat',
'sun',
'eod',
'eow',
'eom',
'eoq',
'eoy',
'sow',
'som',
'soq',
'soy',
'later',
}
end
---@return string[]
local function edit_recur_values()
local ok, recur = pcall(require, 'pending.recur')
if not ok then
return {}
end
local result = {}
for _, s in ipairs(recur.shorthand_list()) do
table.insert(result, s)
end
for _, s in ipairs(recur.shorthand_list()) do
table.insert(result, '!' .. s)
end
return result
end
---@param lead string
---@param candidates string[]
---@return string[]
local function filter_candidates(lead, candidates)
return vim.tbl_filter(function(s)
return s:find(lead, 1, true) == 1
end, candidates)
end
---@param arg_lead string
---@param cmd_line string
---@return string[]
local function complete_edit(arg_lead, cmd_line)
local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
local after_edit = cmd_line:match('^Pending%s+edit%s+(.*)')
if not after_edit then
return {}
end
local parts = {}
for part in after_edit:gmatch('%S+') do
table.insert(parts, part)
end
local trailing_space = after_edit:match('%s$')
if #parts == 0 or (#parts == 1 and not trailing_space) then
local store = require('pending.store')
store.load()
local ids = {}
for _, task in ipairs(store.active_tasks()) do
table.insert(ids, tostring(task.id))
end
return filter_candidates(arg_lead, ids)
end
local prefix = arg_lead:match('^(' .. vim.pesc(dk) .. ':)(.*)$')
if prefix then
local after_colon = arg_lead:sub(#prefix + 1)
local dates = edit_date_values()
local result = {}
for _, d in ipairs(dates) do
if d:find(after_colon, 1, true) == 1 then
table.insert(result, prefix .. d)
end
end
return result
end
local rec_prefix = arg_lead:match('^(' .. vim.pesc(rk) .. ':)(.*)$')
if rec_prefix then
local after_colon = arg_lead:sub(#rec_prefix + 1)
local pats = edit_recur_values()
local result = {}
for _, p in ipairs(pats) do
if p:find(after_colon, 1, true) == 1 then
table.insert(result, rec_prefix .. p)
end
end
return result
end
local cat_prefix = arg_lead:match('^(cat:)(.*)$')
if cat_prefix then
local after_colon = arg_lead:sub(#cat_prefix + 1)
local store = require('pending.store')
store.load()
local seen = {}
local cats = {}
for _, task in ipairs(store.active_tasks()) do
if task.category and not seen[task.category] then
seen[task.category] = true
table.insert(cats, task.category)
end
end
table.sort(cats)
local result = {}
for _, c in ipairs(cats) do
if c:find(after_colon, 1, true) == 1 then
table.insert(result, cat_prefix .. c)
end
end
return result
end
return filter_candidates(arg_lead, edit_field_candidates())
end
vim.api.nvim_create_user_command('Pending', function(opts) vim.api.nvim_create_user_command('Pending', function(opts)
require('pending').command(opts.args) require('pending').command(opts.args)
end, { end, {
bar = true,
nargs = '*', nargs = '*',
complete = function(arg_lead, cmd_line) complete = function(arg_lead, cmd_line)
local subcmds = { 'add', 'archive', 'due', 'edit', 'sync', 'undo' } local subcmds = { 'add', 'sync', 'archive', 'due', 'undo' }
if not cmd_line:match('^Pending%s+%S') then if not cmd_line:match('^Pending%s+%S') then
return filter_candidates(arg_lead, subcmds) return vim.tbl_filter(function(s)
end return s:find(arg_lead, 1, true) == 1
if cmd_line:match('^Pending%s+edit') then end, subcmds)
return complete_edit(arg_lead, cmd_line)
end
if cmd_line:match('^Pending%s+sync') then
local after_sync = cmd_line:match('^Pending%s+sync%s+(.*)')
if not after_sync then
return {}
end
local parts = {}
for part in after_sync:gmatch('%S+') do
table.insert(parts, part)
end
local trailing_space = after_sync:match('%s$')
if #parts == 0 or (#parts == 1 and not trailing_space) then
local backends = {}
local pattern = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
for _, path in ipairs(pattern) do
local name = vim.fn.fnamemodify(path, ':t:r')
table.insert(backends, name)
end
table.sort(backends)
return filter_candidates(arg_lead, backends)
end
if #parts == 1 and trailing_space then
return filter_candidates(arg_lead, { 'auth', 'sync' })
end
if #parts >= 2 and not trailing_space then
return filter_candidates(arg_lead, { 'auth', 'sync' })
end
return {}
end end
return {} return {}
end, end,
@ -209,10 +22,6 @@ vim.keymap.set('n', '<Plug>(pending-open)', function()
require('pending').open() require('pending').open()
end) end)
vim.keymap.set('n', '<Plug>(pending-close)', function()
require('pending.buffer').close()
end)
vim.keymap.set('n', '<Plug>(pending-toggle)', function() vim.keymap.set('n', '<Plug>(pending-toggle)', function()
require('pending').toggle_complete() require('pending').toggle_complete()
end) end)
@ -228,55 +37,3 @@ end)
vim.keymap.set('n', '<Plug>(pending-date)', function() vim.keymap.set('n', '<Plug>(pending-date)', function()
require('pending').prompt_date() require('pending').prompt_date()
end) end)
vim.keymap.set('n', '<Plug>(pending-undo)', function()
require('pending').undo_write()
end)
vim.keymap.set('n', '<Plug>(pending-open-line)', function()
require('pending.buffer').open_line(false)
end)
vim.keymap.set('n', '<Plug>(pending-open-line-above)', function()
require('pending.buffer').open_line(true)
end)
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-a-task)', function()
require('pending.textobj').a_task(vim.v.count1)
end)
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-i-task)', function()
require('pending.textobj').i_task(vim.v.count1)
end)
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-a-category)', function()
require('pending.textobj').a_category(vim.v.count1)
end)
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-i-category)', function()
require('pending.textobj').i_category(vim.v.count1)
end)
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-next-header)', function()
require('pending.textobj').next_header(vim.v.count1)
end)
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-prev-header)', function()
require('pending.textobj').prev_header(vim.v.count1)
end)
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-next-task)', function()
require('pending.textobj').next_task(vim.v.count1)
end)
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-prev-task)', function()
require('pending.textobj').prev_task(vim.v.count1)
end)
vim.keymap.set('n', '<Plug>(pending-goto-file)', function()
require('pending').goto_file()
end)
vim.keymap.set('n', '<Plug>(pending-add-here)', function()
require('pending').add_here()
end)

View file

@ -1,171 +0,0 @@
require('spec.helpers')
local config = require('pending.config')
local store = require('pending.store')
describe('complete', function()
local tmpdir
local complete = require('pending.complete')
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
store.load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
end)
describe('findstart', function()
it('returns column after colon for cat: prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task cat:Wo' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 })
local result = complete.omnifunc(1, '')
assert.are.equal(15, result)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('returns column after colon for due: prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task due:to' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 })
local result = complete.omnifunc(1, '')
assert.are.equal(15, result)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('returns column after colon for rec: prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task rec:we' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 })
local result = complete.omnifunc(1, '')
assert.are.equal(15, result)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('returns -1 for non-token position', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] some task ' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 14 })
local result = complete.omnifunc(1, '')
assert.are.equal(-1, result)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
end)
describe('completions', function()
it('returns existing categories for cat:', function()
store.add({ description = 'A', category = 'Work' })
store.add({ description = 'B', category = 'Home' })
store.add({ description = 'C', category = 'Work' })
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task cat: x' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 15 })
complete.omnifunc(1, '')
local result = complete.omnifunc(0, '')
local words = {}
for _, item in ipairs(result) do
table.insert(words, item.word)
end
assert.is_true(vim.tbl_contains(words, 'Work'))
assert.is_true(vim.tbl_contains(words, 'Home'))
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('filters categories by base', function()
store.add({ description = 'A', category = 'Work' })
store.add({ description = 'B', category = 'Home' })
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task cat:W' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 15 })
complete.omnifunc(1, '')
local result = complete.omnifunc(0, 'W')
assert.are.equal(1, #result)
assert.are.equal('Work', result[1].word)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('returns named dates for due:', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task due: x' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 15 })
complete.omnifunc(1, '')
local result = complete.omnifunc(0, '')
assert.is_true(#result > 0)
local words = {}
for _, item in ipairs(result) do
table.insert(words, item.word)
end
assert.is_true(vim.tbl_contains(words, 'today'))
assert.is_true(vim.tbl_contains(words, 'tomorrow'))
assert.is_true(vim.tbl_contains(words, 'eom'))
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('filters dates by base prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task due:to' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 })
complete.omnifunc(1, '')
local result = complete.omnifunc(0, 'to')
local words = {}
for _, item in ipairs(result) do
table.insert(words, item.word)
end
assert.is_true(vim.tbl_contains(words, 'today'))
assert.is_true(vim.tbl_contains(words, 'tomorrow'))
assert.is_false(vim.tbl_contains(words, 'eom'))
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('returns recurrence shorthands for rec:', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task rec: x' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 15 })
complete.omnifunc(1, '')
local result = complete.omnifunc(0, '')
assert.is_true(#result > 0)
local words = {}
for _, item in ipairs(result) do
table.insert(words, item.word)
end
assert.is_true(vim.tbl_contains(words, 'daily'))
assert.is_true(vim.tbl_contains(words, 'weekly'))
assert.is_true(vim.tbl_contains(words, '!weekly'))
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('filters recurrence by base prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task rec:we' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 })
complete.omnifunc(1, '')
local result = complete.omnifunc(0, 'we')
local words = {}
for _, item in ipairs(result) do
table.insert(words, item.word)
end
assert.is_true(vim.tbl_contains(words, 'weekly'))
assert.is_true(vim.tbl_contains(words, 'weekdays'))
assert.is_false(vim.tbl_contains(words, 'daily'))
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
end)
end)

View file

@ -69,25 +69,6 @@ describe('diff', function()
assert.are.equal('Work', result[2].category) assert.are.equal('Work', result[2].category)
end) end)
it('extracts rec: token from buffer line', function()
local lines = {
'## Inbox',
'/1/- [ ] Take trash out rec:weekly',
}
local result = diff.parse_buffer(lines)
assert.are.equal('weekly', result[2].rec)
end)
it('extracts rec: with completion mode', function()
local lines = {
'## Inbox',
'/1/- [ ] Water plants rec:!daily',
}
local result = diff.parse_buffer(lines)
assert.are.equal('daily', result[2].rec)
assert.are.equal('completion', result[2].rec_mode)
end)
it('inline due: token is parsed', function() it('inline due: token is parsed', function()
local lines = { local lines = {
'## Inbox', '## Inbox',
@ -225,60 +206,6 @@ describe('diff', function()
assert.is_nil(task.due) assert.is_nil(task.due)
end) end)
it('stores recur field on new tasks from buffer', function()
local lines = {
'## Inbox',
'- [ ] Take out trash rec:weekly',
}
diff.apply(lines)
store.unload()
store.load()
local tasks = store.active_tasks()
assert.are.equal(1, #tasks)
assert.are.equal('weekly', tasks[1].recur)
end)
it('updates recur field when changed inline', function()
store.add({ description = 'Task', recur = 'daily' })
store.save()
local lines = {
'## Todo',
'/1/- [ ] Task rec:weekly',
}
diff.apply(lines)
store.unload()
store.load()
local task = store.get(1)
assert.are.equal('weekly', task.recur)
end)
it('clears recur when token removed from line', function()
store.add({ description = 'Task', recur = 'daily' })
store.save()
local lines = {
'## Todo',
'/1/- [ ] Task',
}
diff.apply(lines)
store.unload()
store.load()
local task = store.get(1)
assert.is_nil(task.recur)
end)
it('parses rec: with completion mode prefix', function()
local lines = {
'## Inbox',
'- [ ] Water plants rec:!weekly',
}
diff.apply(lines)
store.unload()
store.load()
local tasks = store.active_tasks()
assert.are.equal('weekly', tasks[1].recur)
assert.are.equal('completion', tasks[1].recur_mode)
end)
it('clears priority when [N] is removed from buffer line', function() it('clears priority when [N] is removed from buffer line', function()
store.add({ description = 'Task name', priority = 1 }) store.add({ description = 'Task name', priority = 1 })
store.save() store.save()

View file

@ -1,304 +0,0 @@
require('spec.helpers')
local config = require('pending.config')
local store = require('pending.store')
describe('edit', function()
local tmpdir
local pending = require('pending')
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
store.load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
end)
it('sets due date with resolve_date vocabulary', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'due:tomorrow')
local updated = store.get(t.id)
local today = os.date('*t') --[[@as osdate]]
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }))
assert.are.equal(expected, updated.due)
end)
it('sets due date with literal YYYY-MM-DD', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'due:2026-06-15')
local updated = store.get(t.id)
assert.are.equal('2026-06-15', updated.due)
end)
it('sets category', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'cat:Work')
local updated = store.get(t.id)
assert.are.equal('Work', updated.category)
end)
it('adds priority', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), '+!')
local updated = store.get(t.id)
assert.are.equal(1, updated.priority)
end)
it('removes priority', function()
local t = store.add({ description = 'Task one', priority = 1 })
store.save()
pending.edit(tostring(t.id), '-!')
local updated = store.get(t.id)
assert.are.equal(0, updated.priority)
end)
it('removes due date', function()
local t = store.add({ description = 'Task one', due = '2026-06-15' })
store.save()
pending.edit(tostring(t.id), '-due')
local updated = store.get(t.id)
assert.is_nil(updated.due)
end)
it('removes category', function()
local t = store.add({ description = 'Task one', category = 'Work' })
store.save()
pending.edit(tostring(t.id), '-cat')
local updated = store.get(t.id)
assert.is_nil(updated.category)
end)
it('sets recurrence', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'rec:weekly')
local updated = store.get(t.id)
assert.are.equal('weekly', updated.recur)
assert.is_nil(updated.recur_mode)
end)
it('sets completion-based recurrence', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'rec:!daily')
local updated = store.get(t.id)
assert.are.equal('daily', updated.recur)
assert.are.equal('completion', updated.recur_mode)
end)
it('removes recurrence', function()
local t = store.add({ description = 'Task one', recur = 'weekly', recur_mode = 'scheduled' })
store.save()
pending.edit(tostring(t.id), '-rec')
local updated = store.get(t.id)
assert.is_nil(updated.recur)
assert.is_nil(updated.recur_mode)
end)
it('applies multiple operations at once', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'due:today cat:Errands +!')
local updated = store.get(t.id)
assert.are.equal(os.date('%Y-%m-%d'), updated.due)
assert.are.equal('Errands', updated.category)
assert.are.equal(1, updated.priority)
end)
it('pushes to undo stack', function()
local t = store.add({ description = 'Task one' })
store.save()
local stack_before = #store.undo_stack()
pending.edit(tostring(t.id), 'cat:Work')
assert.are.equal(stack_before + 1, #store.undo_stack())
end)
it('persists changes to disk', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'cat:Work')
store.unload()
store.load()
local updated = store.get(t.id)
assert.are.equal('Work', updated.category)
end)
it('errors on unknown task ID', function()
store.add({ description = 'Task one' })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit('999', 'cat:Work')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('No task with ID 999'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors on invalid date', function()
local t = store.add({ description = 'Task one' })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), 'due:notadate')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Invalid date'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors on unknown operation token', function()
local t = store.add({ description = 'Task one' })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), 'bogus')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Unknown operation'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors on invalid recurrence pattern', function()
local t = store.add({ description = 'Task one' })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), 'rec:nope')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Invalid recurrence'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors when no operations given', function()
local t = store.add({ description = 'Task one' })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), '')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Usage'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors when no id given', function()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit('', '')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Usage'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors on non-numeric id', function()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit('abc', 'cat:Work')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Invalid task ID'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('shows feedback message on success', function()
local t = store.add({ description = 'Task one' })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), 'cat:Work')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Task #' .. t.id .. ' updated'))
assert.truthy(messages[1].msg:find('category set to Work'))
end)
it('respects custom date_syntax', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', date_syntax = 'by' }
config.reset()
store.unload()
store.load()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'by:tomorrow')
local updated = store.get(t.id)
local today = os.date('*t') --[[@as osdate]]
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }))
assert.are.equal(expected, updated.due)
end)
it('respects custom recur_syntax', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', recur_syntax = 'repeat' }
config.reset()
store.unload()
store.load()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'repeat:weekly')
local updated = store.get(t.id)
assert.are.equal('weekly', updated.recur)
end)
it('does not modify store on error', function()
local t = store.add({ description = 'Task one', category = 'Original' })
store.save()
local orig_notify = vim.notify
vim.notify = function() end
pending.edit(tostring(t.id), 'due:notadate')
vim.notify = orig_notify
local updated = store.get(t.id)
assert.are.equal('Original', updated.category)
assert.is_nil(updated.due)
end)
it('sets due date with datetime format', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'due:tomorrow@14:00')
local updated = store.get(t.id)
local today = os.date('*t') --[[@as osdate]]
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }))
assert.are.equal(expected .. 'T14:00', updated.due)
end)
end)

View file

@ -1,348 +0,0 @@
require('spec.helpers')
local config = require('pending.config')
local parse = require('pending.parse')
local store = require('pending.store')
local diff = require('pending.diff')
local views = require('pending.views')
describe('file token', function()
local tmpdir
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
store.load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
store.unload()
end)
describe('parse.body', function()
it('extracts file token with path and line number', function()
local desc, meta = parse.body('Fix the bug file:src/auth.lua:42')
assert.are.equal('Fix the bug', desc)
assert.are.equal('src/auth.lua:42', meta.file)
end)
it('extracts file token with nested path', function()
local desc, meta = parse.body('Do something file:lua/pending/init.lua:100')
assert.are.equal('Do something', desc)
assert.are.equal('lua/pending/init.lua:100', meta.file)
end)
it('strips file token from description', function()
local desc, meta = parse.body('Task description file:foo.lua:1')
assert.are.equal('Task description', desc)
assert.are.equal('foo.lua:1', meta.file)
end)
it('stops parsing on duplicate file token', function()
local desc, meta = parse.body('Task file:b.lua:2 file:a.lua:1')
assert.are.equal('Task file:b.lua:2', desc)
assert.are.equal('a.lua:1', meta.file)
end)
it('treats malformed file token (no line number) as non-metadata', function()
local desc, meta = parse.body('Task file:nolineno')
assert.are.equal('Task file:nolineno', desc)
assert.is_nil(meta.file)
end)
it('treats file: prefix with no path as non-metadata', function()
local desc, meta = parse.body('Task file:')
assert.are.equal('Task file:', desc)
assert.is_nil(meta.file)
end)
it('handles file token alongside other metadata tokens', function()
local desc, meta = parse.body('Task cat:Work file:src/main.lua:10')
assert.are.equal('Task', desc)
assert.are.equal('Work', meta.cat)
assert.are.equal('src/main.lua:10', meta.file)
end)
it('does not extract file token when line number is not numeric', function()
local desc, meta = parse.body('Task file:src/foo.lua:abc')
assert.are.equal('Task file:src/foo.lua:abc', desc)
assert.is_nil(meta.file)
end)
end)
describe('diff reconciliation', function()
it('stores file field in _extra on write', function()
local t = store.add({ description = 'Task one' })
store.save()
local lines = {
'/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42',
}
diff.apply(lines)
local updated = store.get(t.id)
assert.is_not_nil(updated._extra)
assert.are.equal('src/auth.lua:42', updated._extra.file)
end)
it('updates file field when token changes', function()
local t = store.add({ description = 'Task one', _extra = { file = 'old.lua:1' } })
store.save()
local lines = {
'/' .. t.id .. '/- [ ] Task one file:new.lua:99',
}
diff.apply(lines)
local updated = store.get(t.id)
assert.are.equal('new.lua:99', updated._extra.file)
end)
it('clears file field when token is removed from line', function()
local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } })
store.save()
local lines = {
'/' .. t.id .. '/- [ ] Task one',
}
diff.apply(lines)
local updated = store.get(t.id)
assert.is_nil(updated._extra)
end)
it('preserves other _extra fields when file is cleared', function()
local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc123' } })
store.save()
local lines = {
'/' .. t.id .. '/- [ ] Task one',
}
diff.apply(lines)
local updated = store.get(t.id)
assert.is_not_nil(updated._extra)
assert.is_nil(updated._extra.file)
assert.are.equal('abc123', updated._extra._gcal_event_id)
end)
it('round-trips file field through JSON', function()
local t = store.add({ description = 'Task one' })
store.save()
local lines = {
'/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42',
}
diff.apply(lines)
store.unload()
store.load()
local loaded = store.get(t.id)
assert.is_not_nil(loaded._extra)
assert.are.equal('src/auth.lua:42', loaded._extra.file)
end)
it('accepts optional hidden_ids parameter without error', function()
local t = store.add({ description = 'Task one' })
store.save()
local lines = {
'/' .. t.id .. '/- [ ] Task one',
}
assert.has_no_error(function()
diff.apply(lines, {})
end)
end)
end)
describe('LineMeta', function()
it('category_view populates file field in LineMeta', function()
local t = store.add({
description = 'Task one',
_extra = { file = 'src/auth.lua:42' },
})
store.save()
local tasks = store.active_tasks()
local _, meta = views.category_view(tasks)
local task_meta = nil
for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
task_meta = m
break
end
end
assert.is_not_nil(task_meta)
assert.are.equal('src/auth.lua:42', task_meta.file)
end)
it('priority_view populates file field in LineMeta', function()
local t = store.add({
description = 'Task one',
_extra = { file = 'src/auth.lua:42' },
})
store.save()
local tasks = store.active_tasks()
local _, meta = views.priority_view(tasks)
local task_meta = nil
for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
task_meta = m
break
end
end
assert.is_not_nil(task_meta)
assert.are.equal('src/auth.lua:42', task_meta.file)
end)
it('file field is nil in LineMeta when task has no file', function()
local t = store.add({ description = 'Task one' })
store.save()
local tasks = store.active_tasks()
local _, meta = views.category_view(tasks)
local task_meta = nil
for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
task_meta = m
break
end
end
assert.is_not_nil(task_meta)
assert.is_nil(task_meta.file)
end)
end)
describe(':Pending edit -file', function()
it('clears file reference from task', function()
local pending = require('pending')
local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } })
store.save()
pending.edit(tostring(t.id), '-file')
local updated = store.get(t.id)
assert.is_nil(updated._extra)
end)
it('shows feedback when file reference is removed', function()
local pending = require('pending')
local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), '-file')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('file reference removed'))
end)
it('does not error when task has no file', function()
local pending = require('pending')
local t = store.add({ description = 'Task one' })
store.save()
assert.has_no_error(function()
pending.edit(tostring(t.id), '-file')
end)
end)
it('preserves other _extra fields when -file is used', function()
local pending = require('pending')
local t = store.add({
description = 'Task one',
_extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc' },
})
store.save()
pending.edit(tostring(t.id), '-file')
local updated = store.get(t.id)
assert.is_not_nil(updated._extra)
assert.is_nil(updated._extra.file)
assert.are.equal('abc', updated._extra._gcal_event_id)
end)
end)
describe('goto_file', function()
it('notifies warn when task has no file attached', function()
local pending = require('pending')
local buffer = require('pending.buffer')
local t = store.add({ description = 'Task one' })
store.save()
local bufnr = buffer.open()
vim.bo[bufnr].filetype = 'pending'
vim.api.nvim_set_current_buf(bufnr)
local meta = buffer.meta()
local task_lnum = nil
for lnum, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
task_lnum = lnum
break
end
end
assert.is_not_nil(task_lnum)
vim.api.nvim_win_set_cursor(0, { task_lnum, 0 })
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.goto_file()
vim.notify = orig_notify
local warned = false
for _, m in ipairs(messages) do
if m.level == vim.log.levels.WARN and m.msg:find('No file attached') then
warned = true
end
end
assert.is_true(warned)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('notifies error when file spec is unreadable', function()
local pending = require('pending')
local buffer = require('pending.buffer')
local t = store.add({
description = 'Task one',
_extra = { file = 'nonexistent/path.lua:1' },
})
store.save()
local bufnr = buffer.open()
vim.bo[bufnr].filetype = 'pending'
vim.api.nvim_set_current_buf(bufnr)
local meta = buffer.meta()
local task_lnum = nil
for lnum, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
task_lnum = lnum
break
end
end
assert.is_not_nil(task_lnum)
vim.api.nvim_win_set_cursor(0, { task_lnum, 0 })
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.goto_file()
vim.notify = orig_notify
local errored = false
for _, m in ipairs(messages) do
if m.level == vim.log.levels.ERROR and m.msg:find('File not found') then
errored = true
end
end
assert.is_true(errored)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
end)
end)

View file

@ -154,240 +154,6 @@ describe('parse', function()
local result = parse.resolve_date('') local result = parse.resolve_date('')
assert.is_nil(result) assert.is_nil(result)
end) end)
it("returns yesterday's date for 'yesterday'", function()
local expected = os.date('%Y-%m-%d', os.time() - 86400)
local result = parse.resolve_date('yesterday')
assert.are.equal(expected, result)
end)
it("returns today's date for 'eod'", function()
local result = parse.resolve_date('eod')
assert.are.equal(os.date('%Y-%m-%d'), result)
end)
it('returns Monday of current week for sow', function()
local result = parse.resolve_date('sow')
assert.is_not_nil(result)
local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$')
local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) })
local wday = os.date('*t', t).wday
assert.are.equal(2, wday)
end)
it('returns Sunday of current week for eow', function()
local result = parse.resolve_date('eow')
assert.is_not_nil(result)
local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$')
local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) })
local wday = os.date('*t', t).wday
assert.are.equal(1, wday)
end)
it('returns first day of current month for som', function()
local today = os.date('*t') --[[@as osdate]]
local expected = string.format('%04d-%02d-01', today.year, today.month)
local result = parse.resolve_date('som')
assert.are.equal(expected, result)
end)
it('returns last day of current month for eom', function()
local today = os.date('*t') --[[@as osdate]]
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 }))
local result = parse.resolve_date('eom')
assert.are.equal(expected, result)
end)
it('returns first day of current quarter for soq', function()
local today = os.date('*t') --[[@as osdate]]
local q = math.ceil(today.month / 3)
local first_month = (q - 1) * 3 + 1
local expected = string.format('%04d-%02d-01', today.year, first_month)
local result = parse.resolve_date('soq')
assert.are.equal(expected, result)
end)
it('returns last day of current quarter for eoq', function()
local today = os.date('*t') --[[@as osdate]]
local q = math.ceil(today.month / 3)
local last_month = q * 3
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 }))
local result = parse.resolve_date('eoq')
assert.are.equal(expected, result)
end)
it('returns Jan 1 of current year for soy', function()
local today = os.date('*t') --[[@as osdate]]
local expected = string.format('%04d-01-01', today.year)
local result = parse.resolve_date('soy')
assert.are.equal(expected, result)
end)
it('returns Dec 31 of current year for eoy', function()
local today = os.date('*t') --[[@as osdate]]
local expected = string.format('%04d-12-31', today.year)
local result = parse.resolve_date('eoy')
assert.are.equal(expected, result)
end)
it('resolves +2w to 14 days from today', function()
local today = os.date('*t') --[[@as osdate]]
local expected = os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + 14 })
)
local result = parse.resolve_date('+2w')
assert.are.equal(expected, result)
end)
it('resolves +3m to 3 months from today', function()
local today = os.date('*t') --[[@as osdate]]
local expected = os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month + 3, day = today.day })
)
local result = parse.resolve_date('+3m')
assert.are.equal(expected, result)
end)
it('resolves -2d to 2 days ago', function()
local today = os.date('*t') --[[@as osdate]]
local expected = os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day - 2 })
)
local result = parse.resolve_date('-2d')
assert.are.equal(expected, result)
end)
it('resolves -1w to 7 days ago', function()
local today = os.date('*t') --[[@as osdate]]
local expected = os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day - 7 })
)
local result = parse.resolve_date('-1w')
assert.are.equal(expected, result)
end)
it("resolves 'later' to someday_date", function()
local result = parse.resolve_date('later')
assert.are.equal('9999-12-30', result)
end)
it("resolves 'someday' to someday_date", function()
local result = parse.resolve_date('someday')
assert.are.equal('9999-12-30', result)
end)
it('resolves 15th to next 15th of month', function()
local result = parse.resolve_date('15th')
assert.is_not_nil(result)
local _, _, d = result:match('^(%d+)-(%d+)-(%d+)$')
assert.are.equal('15', d)
end)
it('resolves 1st to next 1st of month', function()
local result = parse.resolve_date('1st')
assert.is_not_nil(result)
local _, _, d = result:match('^(%d+)-(%d+)-(%d+)$')
assert.are.equal('01', d)
end)
it('resolves jan to next January 1st', function()
local today = os.date('*t') --[[@as osdate]]
local result = parse.resolve_date('jan')
assert.is_not_nil(result)
local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$')
assert.are.equal('01', m)
assert.are.equal('01', d)
if today.month >= 1 then
assert.are.equal(tostring(today.year + 1), y)
end
end)
it('resolves dec to next December 1st', function()
local today = os.date('*t') --[[@as osdate]]
local result = parse.resolve_date('dec')
assert.is_not_nil(result)
local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$')
assert.are.equal('12', m)
assert.are.equal('01', d)
if today.month >= 12 then
assert.are.equal(tostring(today.year + 1), y)
else
assert.are.equal(tostring(today.year), y)
end
end)
end)
describe('resolve_date with time suffix', function()
local today = os.date('*t') --[[@as osdate]]
local tomorrow_str =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) --[[@as string]]
it('resolves bare hour to T09:00', function()
local result = parse.resolve_date('tomorrow@9')
assert.are.equal(tomorrow_str .. 'T09:00', result)
end)
it('resolves bare military hour to T14:00', function()
local result = parse.resolve_date('tomorrow@14')
assert.are.equal(tomorrow_str .. 'T14:00', result)
end)
it('resolves H:MM to T09:30', function()
local result = parse.resolve_date('tomorrow@9:30')
assert.are.equal(tomorrow_str .. 'T09:30', result)
end)
it('resolves HH:MM (existing format) to T09:30', function()
local result = parse.resolve_date('tomorrow@09:30')
assert.are.equal(tomorrow_str .. 'T09:30', result)
end)
it('resolves 2pm to T14:00', function()
local result = parse.resolve_date('tomorrow@2pm')
assert.are.equal(tomorrow_str .. 'T14:00', result)
end)
it('resolves 9am to T09:00', function()
local result = parse.resolve_date('tomorrow@9am')
assert.are.equal(tomorrow_str .. 'T09:00', result)
end)
it('resolves 9:30pm to T21:30', function()
local result = parse.resolve_date('tomorrow@9:30pm')
assert.are.equal(tomorrow_str .. 'T21:30', result)
end)
it('resolves 12am to T00:00', function()
local result = parse.resolve_date('tomorrow@12am')
assert.are.equal(tomorrow_str .. 'T00:00', result)
end)
it('resolves 12pm to T12:00', function()
local result = parse.resolve_date('tomorrow@12pm')
assert.are.equal(tomorrow_str .. 'T12:00', result)
end)
it('rejects hour 24', function()
assert.is_nil(parse.resolve_date('tomorrow@24'))
end)
it('rejects 13am', function()
assert.is_nil(parse.resolve_date('tomorrow@13am'))
end)
it('rejects minute 60', function()
assert.is_nil(parse.resolve_date('tomorrow@9:60'))
end)
it('rejects alphabetic garbage', function()
assert.is_nil(parse.resolve_date('tomorrow@abc'))
end)
end) end)
describe('command_add', function() describe('command_add', function()

View file

@ -1,223 +0,0 @@
require('spec.helpers')
describe('recur', function()
local recur = require('pending.recur')
describe('parse', function()
it('parses daily', function()
local r = recur.parse('daily')
assert.are.equal('daily', r.freq)
assert.are.equal(1, r.interval)
assert.is_false(r.from_completion)
end)
it('parses weekdays', function()
local r = recur.parse('weekdays')
assert.are.equal('weekly', r.freq)
assert.are.same({ 'MO', 'TU', 'WE', 'TH', 'FR' }, r.byday)
end)
it('parses weekly', function()
local r = recur.parse('weekly')
assert.are.equal('weekly', r.freq)
assert.are.equal(1, r.interval)
end)
it('parses biweekly', function()
local r = recur.parse('biweekly')
assert.are.equal('weekly', r.freq)
assert.are.equal(2, r.interval)
end)
it('parses monthly', function()
local r = recur.parse('monthly')
assert.are.equal('monthly', r.freq)
assert.are.equal(1, r.interval)
end)
it('parses quarterly', function()
local r = recur.parse('quarterly')
assert.are.equal('monthly', r.freq)
assert.are.equal(3, r.interval)
end)
it('parses yearly', function()
local r = recur.parse('yearly')
assert.are.equal('yearly', r.freq)
assert.are.equal(1, r.interval)
end)
it('parses annual as yearly', function()
local r = recur.parse('annual')
assert.are.equal('yearly', r.freq)
end)
it('parses 3d as every 3 days', function()
local r = recur.parse('3d')
assert.are.equal('daily', r.freq)
assert.are.equal(3, r.interval)
end)
it('parses 2w as biweekly', function()
local r = recur.parse('2w')
assert.are.equal('weekly', r.freq)
assert.are.equal(2, r.interval)
end)
it('parses 6m as every 6 months', function()
local r = recur.parse('6m')
assert.are.equal('monthly', r.freq)
assert.are.equal(6, r.interval)
end)
it('parses 2y as every 2 years', function()
local r = recur.parse('2y')
assert.are.equal('yearly', r.freq)
assert.are.equal(2, r.interval)
end)
it('parses ! prefix as completion-based', function()
local r = recur.parse('!weekly')
assert.are.equal('weekly', r.freq)
assert.is_true(r.from_completion)
end)
it('parses raw RRULE fragment', function()
local r = recur.parse('FREQ=MONTHLY;BYDAY=1MO')
assert.is_not_nil(r)
end)
it('returns nil for invalid input', function()
assert.is_nil(recur.parse(''))
assert.is_nil(recur.parse('garbage'))
assert.is_nil(recur.parse('0d'))
end)
it('is case insensitive', function()
local r = recur.parse('Weekly')
assert.are.equal('weekly', r.freq)
end)
end)
describe('validate', function()
it('returns true for valid specs', function()
assert.is_true(recur.validate('daily'))
assert.is_true(recur.validate('2w'))
assert.is_true(recur.validate('!monthly'))
end)
it('returns false for invalid specs', function()
assert.is_false(recur.validate('garbage'))
assert.is_false(recur.validate(''))
end)
end)
describe('next_due', function()
it('advances daily by 1 day', function()
local result = recur.next_due('2099-03-01', 'daily', 'scheduled')
assert.are.equal('2099-03-02', result)
end)
it('advances weekly by 7 days', function()
local result = recur.next_due('2099-03-01', 'weekly', 'scheduled')
assert.are.equal('2099-03-08', result)
end)
it('advances monthly and clamps day', function()
local result = recur.next_due('2099-01-31', 'monthly', 'scheduled')
assert.are.equal('2099-02-28', result)
end)
it('advances yearly and handles leap year', function()
local result = recur.next_due('2096-02-29', 'yearly', 'scheduled')
assert.are.equal('2097-02-28', result)
end)
it('advances biweekly by 14 days', function()
local result = recur.next_due('2099-03-01', 'biweekly', 'scheduled')
assert.are.equal('2099-03-15', result)
end)
it('advances quarterly by 3 months', function()
local result = recur.next_due('2099-01-15', 'quarterly', 'scheduled')
assert.are.equal('2099-04-15', result)
end)
it('scheduled mode skips to future if overdue', function()
local result = recur.next_due('2020-01-01', 'yearly', 'scheduled')
local today = os.date('%Y-%m-%d') --[[@as string]]
assert.is_true(result > today)
end)
it('completion mode advances from today', function()
local today = os.date('*t') --[[@as osdate]]
local expected = os.date(
'%Y-%m-%d',
os.time({
year = today.year,
month = today.month,
day = today.day + 7,
})
)
local result = recur.next_due('2020-01-01', 'weekly', 'completion')
assert.are.equal(expected, result)
end)
it('advances 3d by 3 days', function()
local result = recur.next_due('2099-06-10', '3d', 'scheduled')
assert.are.equal('2099-06-13', result)
end)
end)
describe('to_rrule', function()
it('converts daily', function()
assert.are.equal('RRULE:FREQ=DAILY', recur.to_rrule('daily'))
end)
it('converts weekly', function()
assert.are.equal('RRULE:FREQ=WEEKLY', recur.to_rrule('weekly'))
end)
it('converts biweekly with interval', function()
assert.are.equal('RRULE:FREQ=WEEKLY;INTERVAL=2', recur.to_rrule('biweekly'))
end)
it('converts weekdays with BYDAY', function()
assert.are.equal('RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', recur.to_rrule('weekdays'))
end)
it('converts monthly', function()
assert.are.equal('RRULE:FREQ=MONTHLY', recur.to_rrule('monthly'))
end)
it('converts quarterly with interval', function()
assert.are.equal('RRULE:FREQ=MONTHLY;INTERVAL=3', recur.to_rrule('quarterly'))
end)
it('converts yearly', function()
assert.are.equal('RRULE:FREQ=YEARLY', recur.to_rrule('yearly'))
end)
it('converts 2w with interval', function()
assert.are.equal('RRULE:FREQ=WEEKLY;INTERVAL=2', recur.to_rrule('2w'))
end)
it('prefixes raw RRULE fragment', function()
assert.are.equal('RRULE:FREQ=MONTHLY;BYDAY=1MO', recur.to_rrule('FREQ=MONTHLY;BYDAY=1MO'))
end)
it('returns empty string for invalid spec', function()
assert.are.equal('', recur.to_rrule('garbage'))
end)
end)
describe('shorthand_list', function()
it('returns a list of named shorthands', function()
local list = recur.shorthand_list()
assert.is_true(#list >= 8)
assert.is_true(vim.tbl_contains(list, 'daily'))
assert.is_true(vim.tbl_contains(list, 'weekly'))
assert.is_true(vim.tbl_contains(list, 'monthly'))
end)
end)
end)

View file

@ -1,264 +0,0 @@
require('spec.helpers')
local config = require('pending.config')
local parse = require('pending.parse')
local store = require('pending.store')
describe('status', function()
local tmpdir
local pending
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
package.loaded['pending'] = nil
pending = require('pending')
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
store.unload()
package.loaded['pending'] = nil
end)
describe('counts', function()
it('returns zeroes for empty store', function()
store.load()
local c = pending.counts()
assert.are.equal(0, c.overdue)
assert.are.equal(0, c.today)
assert.are.equal(0, c.pending)
assert.are.equal(0, c.priority)
assert.is_nil(c.next_due)
end)
it('counts pending tasks', function()
store.load()
store.add({ description = 'One' })
store.add({ description = 'Two' })
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(2, c.pending)
end)
it('counts priority tasks', function()
store.load()
store.add({ description = 'Urgent', priority = 1 })
store.add({ description = 'Normal' })
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.priority)
end)
it('counts overdue tasks with date-only', function()
store.load()
store.add({ description = 'Old task', due = '2020-01-01' })
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.overdue)
end)
it('counts overdue tasks with datetime', function()
store.load()
store.add({ description = 'Old task', due = '2020-01-01T08:00' })
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.overdue)
end)
it('counts today tasks', function()
store.load()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Today task', due = today })
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.today)
assert.are.equal(0, c.overdue)
end)
it('counts mixed overdue and today', function()
store.load()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Overdue', due = '2020-01-01' })
store.add({ description = 'Today', due = today })
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.overdue)
assert.are.equal(1, c.today)
end)
it('excludes done tasks', function()
store.load()
local t = store.add({ description = 'Done', due = '2020-01-01' })
store.update(t.id, { status = 'done' })
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(0, c.overdue)
assert.are.equal(0, c.pending)
end)
it('excludes deleted tasks', function()
store.load()
local t = store.add({ description = 'Deleted', due = '2020-01-01' })
store.delete(t.id)
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(0, c.overdue)
assert.are.equal(0, c.pending)
end)
it('excludes someday sentinel', function()
store.load()
store.add({ description = 'Someday', due = '9999-12-30' })
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(0, c.overdue)
assert.are.equal(0, c.today)
assert.are.equal(1, c.pending)
end)
it('picks earliest future date as next_due', function()
store.load()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Soon', due = '2099-06-01' })
store.add({ description = 'Sooner', due = '2099-03-01' })
store.add({ description = 'Today', due = today })
store.save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(today, c.next_due)
end)
it('lazy loads on first counts() call', function()
local path = config.get().data_path
local f = io.open(path, 'w')
f:write(vim.json.encode({
version = 1,
next_id = 2,
tasks = {
{
id = 1,
description = 'Overdue',
status = 'pending',
due = '2020-01-01',
entry = '2020-01-01T00:00:00Z',
modified = '2020-01-01T00:00:00Z',
},
},
}))
f:close()
store.unload()
package.loaded['pending'] = nil
pending = require('pending')
local c = pending.counts()
assert.are.equal(1, c.overdue)
end)
end)
describe('statusline', function()
it('returns empty string when nothing actionable', function()
store.load()
store.save()
pending._recompute_counts()
assert.are.equal('', pending.statusline())
end)
it('formats overdue only', function()
store.load()
store.add({ description = 'Old', due = '2020-01-01' })
store.save()
pending._recompute_counts()
assert.are.equal('1 overdue', pending.statusline())
end)
it('formats today only', function()
store.load()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Today', due = today })
store.save()
pending._recompute_counts()
assert.are.equal('1 today', pending.statusline())
end)
it('formats overdue and today', function()
store.load()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Old', due = '2020-01-01' })
store.add({ description = 'Today', due = today })
store.save()
pending._recompute_counts()
assert.are.equal('1 overdue, 1 today', pending.statusline())
end)
end)
describe('has_due', function()
it('returns false when nothing due', function()
store.load()
store.add({ description = 'Future', due = '2099-01-01' })
store.save()
pending._recompute_counts()
assert.is_false(pending.has_due())
end)
it('returns true when overdue', function()
store.load()
store.add({ description = 'Old', due = '2020-01-01' })
store.save()
pending._recompute_counts()
assert.is_true(pending.has_due())
end)
it('returns true when today', function()
store.load()
local today = os.date('%Y-%m-%d') --[[@as string]]
store.add({ description = 'Now', due = today })
store.save()
pending._recompute_counts()
assert.is_true(pending.has_due())
end)
end)
describe('parse.is_overdue', function()
it('date before today is overdue', function()
assert.is_true(parse.is_overdue('2020-01-01'))
end)
it('date after today is not overdue', function()
assert.is_false(parse.is_overdue('2099-01-01'))
end)
it('today date-only is not overdue', function()
local today = os.date('%Y-%m-%d') --[[@as string]]
assert.is_false(parse.is_overdue(today))
end)
end)
describe('parse.is_today', function()
it('today date-only is today', function()
local today = os.date('%Y-%m-%d') --[[@as string]]
assert.is_true(parse.is_today(today))
end)
it('yesterday is not today', function()
assert.is_false(parse.is_today('2020-01-01'))
end)
it('tomorrow is not today', function()
assert.is_false(parse.is_today('2099-01-01'))
end)
end)
end)

View file

@ -196,41 +196,6 @@ describe('store', function()
end) end)
end) end)
describe('recurrence fields', function()
it('persists recur and recur_mode through round-trip', function()
store.load()
store.add({ description = 'Recurring', recur = 'weekly', recur_mode = 'scheduled' })
store.save()
store.unload()
store.load()
local task = store.get(1)
assert.are.equal('weekly', task.recur)
assert.are.equal('scheduled', task.recur_mode)
end)
it('persists recur without recur_mode', function()
store.load()
store.add({ description = 'Simple recur', recur = 'daily' })
store.save()
store.unload()
store.load()
local task = store.get(1)
assert.are.equal('daily', task.recur)
assert.is_nil(task.recur_mode)
end)
it('omits recur fields when not set', function()
store.load()
store.add({ description = 'No recur' })
store.save()
store.unload()
store.load()
local task = store.get(1)
assert.is_nil(task.recur)
assert.is_nil(task.recur_mode)
end)
end)
describe('active_tasks', function() describe('active_tasks', function()
it('excludes deleted tasks', function() it('excludes deleted tasks', function()
store.load() store.load()

View file

@ -1,174 +0,0 @@
require('spec.helpers')
local config = require('pending.config')
local store = require('pending.store')
describe('sync', function()
local tmpdir
local pending
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
package.loaded['pending'] = nil
pending = require('pending')
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
store.unload()
package.loaded['pending'] = nil
end)
describe('dispatch', function()
it('errors on bare :Pending sync with no backend', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.sync(nil)
vim.notify = orig
assert.are.equal('Usage: :Pending sync <backend> [action]', msg)
end)
it('errors on empty backend string', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.sync('')
vim.notify = orig
assert.are.equal('Usage: :Pending sync <backend> [action]', msg)
end)
it('errors on unknown backend', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.sync('notreal')
vim.notify = orig
assert.are.equal('Unknown sync backend: notreal', msg)
end)
it('errors on unknown action for valid backend', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.sync('gcal', 'notreal')
vim.notify = orig
assert.are.equal("gcal backend has no 'notreal' action", msg)
end)
it('defaults to sync action when action is omitted', function()
local called = false
local gcal = require('pending.sync.gcal')
local orig_sync = gcal.sync
gcal.sync = function()
called = true
end
pending.sync('gcal')
gcal.sync = orig_sync
assert.is_true(called)
end)
it('routes explicit sync action', function()
local called = false
local gcal = require('pending.sync.gcal')
local orig_sync = gcal.sync
gcal.sync = function()
called = true
end
pending.sync('gcal', 'sync')
gcal.sync = orig_sync
assert.is_true(called)
end)
it('routes auth action', function()
local called = false
local gcal = require('pending.sync.gcal')
local orig_auth = gcal.auth
gcal.auth = function()
called = true
end
pending.sync('gcal', 'auth')
gcal.auth = orig_auth
assert.is_true(called)
end)
end)
describe('config migration', function()
it('migrates legacy gcal to sync.gcal', function()
config.reset()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
gcal = { calendar = 'MyCalendar' },
}
local cfg = config.get()
assert.is_not_nil(cfg.sync)
assert.is_not_nil(cfg.sync.gcal)
assert.are.equal('MyCalendar', cfg.sync.gcal.calendar)
end)
it('does not overwrite explicit sync.gcal with legacy gcal', function()
config.reset()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
gcal = { calendar = 'Legacy' },
sync = { gcal = { calendar = 'Explicit' } },
}
local cfg = config.get()
assert.are.equal('Explicit', cfg.sync.gcal.calendar)
end)
it('works with sync.gcal and no legacy gcal', function()
config.reset()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
sync = { gcal = { calendar = 'NewStyle' } },
}
local cfg = config.get()
assert.are.equal('NewStyle', cfg.sync.gcal.calendar)
end)
end)
describe('gcal module', function()
it('has name field', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('gcal', gcal.name)
end)
it('has auth function', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('function', type(gcal.auth))
end)
it('has sync function', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('function', type(gcal.sync))
end)
it('has health function', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('function', type(gcal.health))
end)
end)
end)

View file

@ -1,194 +0,0 @@
require('spec.helpers')
local config = require('pending.config')
describe('textobj', function()
local textobj = require('pending.textobj')
before_each(function()
vim.g.pending = nil
config.reset()
end)
after_each(function()
vim.g.pending = nil
config.reset()
end)
describe('inner_task_range', function()
it('returns description range for task with id prefix', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries')
assert.are.equal(10, s)
assert.are.equal(22, e)
end)
it('returns description range for task without id prefix', function()
local s, e = textobj.inner_task_range('- [ ] Buy groceries')
assert.are.equal(7, s)
assert.are.equal(19, e)
end)
it('excludes trailing due: token', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries due:2026-03-15')
assert.are.equal(10, s)
assert.are.equal(22, e)
end)
it('excludes trailing cat: token', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries cat:Errands')
assert.are.equal(10, s)
assert.are.equal(22, e)
end)
it('excludes trailing rec: token', function()
local s, e = textobj.inner_task_range('/1/- [ ] Take out trash rec:weekly')
assert.are.equal(10, s)
assert.are.equal(23, e)
end)
it('excludes multiple trailing metadata tokens', function()
local s, e =
textobj.inner_task_range('/1/- [ ] Buy milk due:2026-03-15 cat:Errands rec:weekly')
assert.are.equal(10, s)
assert.are.equal(17, e)
end)
it('handles priority checkbox', function()
local s, e = textobj.inner_task_range('/1/- [!] Important task')
assert.are.equal(10, s)
assert.are.equal(23, e)
end)
it('handles done checkbox', function()
local s, e = textobj.inner_task_range('/1/- [x] Finished task')
assert.are.equal(10, s)
assert.are.equal(22, e)
end)
it('handles multi-digit task ids', function()
local s, e = textobj.inner_task_range('/123/- [ ] Some task')
assert.are.equal(12, s)
assert.are.equal(20, e)
end)
it('does not strip non-metadata tokens', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries for dinner')
assert.are.equal(10, s)
assert.are.equal(33, e)
end)
it('stops stripping at first non-metadata token from right', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries for dinner due:2026-03-15')
assert.are.equal(10, s)
assert.are.equal(33, e)
end)
it('respects custom date_syntax', function()
vim.g.pending = { date_syntax = 'by' }
config.reset()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries by:2026-03-15')
assert.are.equal(10, s)
assert.are.equal(22, e)
end)
it('respects custom recur_syntax', function()
vim.g.pending = { recur_syntax = 'repeat' }
config.reset()
local s, e = textobj.inner_task_range('/1/- [ ] Take trash repeat:weekly')
assert.are.equal(10, s)
assert.are.equal(19, e)
end)
it('handles task with only metadata after description', function()
local s, e = textobj.inner_task_range('/1/- [ ] X due:tomorrow')
assert.are.equal(10, s)
assert.are.equal(10, e)
end)
end)
describe('category_bounds', function()
it('returns header and last row for single category', function()
---@type pending.LineMeta[]
local meta = {
{ type = 'header', category = 'Work' },
{ type = 'task', id = 1 },
{ type = 'task', id = 2 },
}
local h, l = textobj.category_bounds(2, meta)
assert.are.equal(1, h)
assert.are.equal(3, l)
end)
it('returns bounds for first category with trailing blank', function()
---@type pending.LineMeta[]
local meta = {
{ type = 'header', category = 'Work' },
{ type = 'task', id = 1 },
{ type = 'blank' },
{ type = 'header', category = 'Personal' },
{ type = 'task', id = 2 },
}
local h, l = textobj.category_bounds(2, meta)
assert.are.equal(1, h)
assert.are.equal(3, l)
end)
it('returns bounds for second category', function()
---@type pending.LineMeta[]
local meta = {
{ type = 'header', category = 'Work' },
{ type = 'task', id = 1 },
{ type = 'blank' },
{ type = 'header', category = 'Personal' },
{ type = 'task', id = 2 },
{ type = 'task', id = 3 },
}
local h, l = textobj.category_bounds(5, meta)
assert.are.equal(4, h)
assert.are.equal(6, l)
end)
it('returns bounds when cursor is on header', function()
---@type pending.LineMeta[]
local meta = {
{ type = 'header', category = 'Work' },
{ type = 'task', id = 1 },
}
local h, l = textobj.category_bounds(1, meta)
assert.are.equal(1, h)
assert.are.equal(2, l)
end)
it('returns nil for blank line with no preceding header', function()
---@type pending.LineMeta[]
local meta = {
{ type = 'blank' },
{ type = 'header', category = 'Work' },
{ type = 'task', id = 1 },
}
local h, l = textobj.category_bounds(1, meta)
assert.is_nil(h)
assert.is_nil(l)
end)
it('returns nil for empty meta', function()
local h, l = textobj.category_bounds(1, {})
assert.is_nil(h)
assert.is_nil(l)
end)
it('includes blank between header and next header in bounds', function()
---@type pending.LineMeta[]
local meta = {
{ type = 'header', category = 'Work' },
{ type = 'task', id = 1 },
{ type = 'blank' },
{ type = 'header', category = 'Home' },
{ type = 'task', id = 2 },
}
local h, l = textobj.category_bounds(1, meta)
assert.are.equal(1, h)
assert.are.equal(3, l)
end)
end)
end)

View file

@ -204,30 +204,6 @@ describe('views', function()
assert.is_falsy(task_meta.overdue) assert.is_falsy(task_meta.overdue)
end) end)
it('includes recur in LineMeta for recurring tasks', function()
store.add({ description = 'Recurring', category = 'Inbox', recur = 'weekly' })
local _, meta = views.category_view(store.active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' then
task_meta = m
end
end
assert.are.equal('weekly', task_meta.recur)
end)
it('has nil recur in LineMeta for non-recurring tasks', function()
store.add({ description = 'Normal', category = 'Inbox' })
local _, meta = views.category_view(store.active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' then
task_meta = m
end
end
assert.is_nil(task_meta.recur)
end)
it('respects category_order when set', function() it('respects category_order when set', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work', 'Inbox' } } vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work', 'Inbox' } }
config.reset() config.reset()
@ -423,29 +399,5 @@ describe('views', function()
end end
assert.is_falsy(task_meta.overdue) assert.is_falsy(task_meta.overdue)
end) end)
it('includes recur in LineMeta for recurring tasks', function()
store.add({ description = 'Recurring', category = 'Inbox', recur = 'daily' })
local _, meta = views.priority_view(store.active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' then
task_meta = m
end
end
assert.are.equal('daily', task_meta.recur)
end)
it('has nil recur in LineMeta for non-recurring tasks', function()
store.add({ description = 'Normal', category = 'Inbox' })
local _, meta = views.priority_view(store.active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' then
task_meta = m
end
end
assert.is_nil(task_meta.recur)
end)
end) end)
end) end)