Compare commits

...
Sign in to create a new pull request.

62 commits

Author SHA1 Message Date
b6c28eb7b3 feat(buffer): add configurable eol_format for EOL virtual text
Problem: EOL virtual text order (category → recurrence → due) and the
double-space separator are hardcoded in `apply_extmarks()`. Users cannot
reorder, omit, or restyle metadata fields.

Solution: Add `eol_format` config field (default `'%c  %r  %d'`) with
`%c`, `%r`, `%d` specifiers. `parse_eol_format()` tokenizes the format
string; `build_eol_virt()` resolves specifiers against `LineMeta` and
collapses literals around absent fields.
2026-03-08 14:25:22 -04:00
27f46ae0dd fix(buffer): suppress on_bytes during render and fix definition order
Problem: `on_bytes` fired during `render()`'s `nvim_buf_set_lines`,
corrupting `_meta` with duplicate entries and causing out-of-range
extmark errors. Also, `apply_inline_row` was defined after its first
caller `reapply_dirty_inline`.

Solution: add `_rendering` guard flag around `nvim_buf_set_lines` in
`render()` so `on_bytes` is a no-op during authoritative renders.
Move `apply_inline_row` above `reapply_dirty_inline` to satisfy Lua
local scoping rules.
2026-03-08 14:17:47 -04:00
e71d6cdff6 feat(buffer): re-apply inline extmarks after edits
Problem: inline extmarks (checkbox overlays, strikethrough, header
highlights) were cleared during edits and only restored on `:w`,
leaving the buffer visually bare while editing.

Solution: extract `apply_inline_row()` from `apply_extmarks()` and
call it via `reapply_dirty_inline()` on `InsertLeave` and normal-mode
`TextChanged`. Insert-mode `TextChangedI` still only clears inline
marks on dirty rows to avoid overlay flicker while typing.
2026-03-08 14:12:03 -04:00
ec08ca9645 feat(buffer): clear only inline extmarks on dirty rows during edits
Problem: `TextChanged` cleared all extmarks (both namespaces) on every
edit, causing EOL virtual text (due dates, recurrence) to vanish while
the user types.

Solution: replace blanket `clear_marks()` with per-row
`clear_inline_row()` that only removes `ns_inline` extmarks on rows
flagged dirty by `on_bytes`. EOL virtual text is preserved untouched.
2026-03-08 14:10:33 -04:00
db391c5715 feat(buffer): track line changes via on_bytes to keep _meta aligned
Problem: `_meta` is a positional array keyed by line number. Line
insertions and deletions during editing desync it from actual buffer
content, breaking `get_fold()`, cursor-based task lookups, and extmark
re-application.

Solution: attach an `on_bytes` callback that adjusts `_meta` on line
insertions/deletions and tracks dirty rows. Remove the manual
`_meta` insert from `open_line()` since `on_bytes` now handles it.
Reset dirty rows on each full render.
2026-03-08 14:09:36 -04:00
a12e5b5763 refactor(buffer): split extmark namespace into ns_eol and ns_inline
Problem: all extmarks shared a single `pending` namespace, making it
impossible to selectively clear position-sensitive extmarks (overlays,
highlights) while preserving stable EOL virtual text (due dates,
recurrence).

Solution: introduce `ns_eol` for end-of-line virtual text and
`ns_inline` for overlays and highlights. `clear_marks()` and
`apply_extmarks()` operate on both namespaces independently.
2026-03-08 14:08:15 -04:00
d06731a7fd ci: format 2026-03-08 13:24:44 -04:00
Barrett Ruth
9af6086959
feat(buffer): persist fold state across sessions (#94)
Some checks are pending
luarocks / quality (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
Problem: folded category headers are lost when Neovim exits because
`_fold_state` only lives in memory. Users must re-fold categories
every session.

Solution: store folded category names in the JSON data file as a
top-level `folded_categories` field. On first render, `restore_folds`
seeds from the store instead of the empty in-memory state. Folds are
persisted on `M.close()` and `VimLeavePre`.
2026-03-07 20:18:34 -05:00
26d43688d0 ci: format 2026-03-07 01:59:10 -05:00
Barrett Ruth
0176592ae2
Enhance README with bold text and context
Emphasize task editing feature and provide additional context.
2026-03-07 01:59:02 -05:00
Barrett Ruth
09757a593b
Fix demo image link in README
Updated demo image link in README.
2026-03-07 01:58:09 -05:00
79aeeba9bb fix: minor login
Some checks are pending
luarocks / quality (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
2026-03-07 01:54:09 -05:00
522daf3a21 ci: format 2026-03-07 01:38:24 -05:00
Barrett Ruth
d176ccccd1
fix(init): fix cursor position when navigating from quickfix (#93)
Problem: `:Pending due` quickfix items landed on line 1 instead of
the task line. The `BufEnter` redirect branch captured cursor before
quickfix had positioned it in the new window, so the stale position
was used when transferring focus back to the registered pending window.

Solution: move cursor capture inside `vim.schedule` so it reads after
quickfix navigation has completed. Also guard `clear_marks` behind a
`modified` check so extmarks are only cleared on actual edits.
2026-03-07 01:38:12 -05:00
5fe6dcecad ci: cleanup 2026-03-06 21:36:46 -05:00
Barrett Ruth
559ab863a8
fix(buffer): fix stale extmarks, duplicate window, and fold state loss (#92)
Problem: Deleting lines (`dd`, `dat`, `d3j`) left extmarks stranded on
adjacent rows since `render()` only clears and reapplies marks on `:w`.
Quickfix `<CR>` opened the pending buffer in a second window because
`BufEnter` did not redirect to `task_winid`. Category fold state was
lost across `<Tab>/<Tab>` view toggles because `render()` overwrote the
saved state with an empty snapshot taken while folds were disabled.

Solution: Add a `TextChanged`/`TextChangedI` autocmd that clears the
extmark namespace immediately on any edit. Fix `BufEnter` to close
duplicate windows and redirect focus to `task_winid`, updating it when
stale. Fix `snapshot_folds` to skip if a state is already saved, and
`restore_folds` to always clear the saved state; snapshot in
`toggle_view` before the view flips so the state survives the round-trip.
2026-03-06 21:36:04 -05:00
Barrett Ruth
c392874311
feat(buffer): add configurable category-level folds (#91)
Problem: category folds were hardcoded with no config option, no custom
foldtext, and no vimdoc coverage.

Solution: add `folding` config field (boolean or table with `foldtext`
format string). Default foldtext is `%c (%n tasks)` with automatic
singular/plural. Gate all fold logic on the config so `folding = false`
disables folds entirely. Document the new option in vimdoc.
2026-03-06 20:08:49 -05:00
Barrett Ruth
12b9295c34
refactor: normalize log message grammar and capitalization (#89)
Problem: Log messages used inconsistent capitalization, punctuation,
and phrasing — some started lowercase, some omitted periods, "Pending"
was used instead of "task", and sync backend errors used ad-hoc
formatting.

Solution: Apply sentence case after backend prefixes, add trailing
periods to complete sentences, rename "Pending" to "task", use
`'Failed to <verb> <noun>: '` pattern for operation errors, and
pluralize "Archived N task(s)" correctly.
2026-03-06 18:38:17 -05:00
Barrett Ruth
874ff381f9
fix: resolve OAuth re-auth deadlock and sync concurrency races (#88)
* fix(gtasks): prevent concurrent push/pull from racing on the store

Problem: `push` and `pull` both run via `oauth.async`, so issuing them
back-to-back starts two coroutines that interleave at every curl yield.
Both snapshot `build_id_index` before either has mutated the store,
which can cause push to create a remote task that pull would have
recognized as already linked, producing duplicates on Google.

Solution: guard `with_token` with a module-level `_in_flight` flag set
before `oauth.async` is called so no second operation can start during
a token-refresh yield. A `pcall` around the callback guarantees the
flag is always cleared, even on an unexpected error.

* refactor(sync): centralize `with_token` in oauth.lua with shared lock

Problem: `with_token` was duplicated in `gcal.lua` and `gtasks.lua`,
with the concurrency lock added only to the gtasks copy. Any new
backend would silently inherit the same race, and gcal back-to-back
push could still create duplicate remote calendar events.

Solution: lift `with_token` into `oauth.lua` as
`M.with_token(client, name, callback)` behind a module-level
`_sync_in_flight` guard. All backends share one implementation; the
lock covers gcal, gtasks, and any future backend automatically.

* ci: format
2026-03-06 16:09:45 -05:00
Barrett Ruth
b641c93a0a
fix(oauth): resolve re-auth deadlock and improve flow robustness (#87)
* fix(oauth): resolve re-auth deadlock and improve flow robustness

Problem: in-flight TCP server held port 18392 for up to 120 seconds.
Calling `auth()` again caused `bind()` to fail silently — the browser
opened but no listener could receive the OAuth callback. `_wipe()` on
exchange failure also destroyed credentials, forcing full re-setup.

Solution: `_active_close` at module scope cancels any in-flight server
when `auth()` or `clear_tokens()` is called. Binding is guarded with
`pcall`; the browser only opens after the server is listening. Swapped
`_wipe()` for `clear_tokens()` in `_exchange_code` to preserve
credentials on failure. Added `select_account` to `prompt` so Google
always shows the account picker on re-auth.

* test(oauth): isolate bundled-credentials fallback from real filesystem

Problem: `resolve_credentials` reads from `vim.fn.stdpath('data')`,
the real Neovim data dir. The test passed only because `_wipe()` was
incidentally deleting the user's credential file mid-run.

Solution: stub `oauth.load_json_file` for the duration of the test so
real credential files cannot interfere with the fallback assertion.

* ci: format
2026-03-06 15:47:42 -05:00
Barrett Ruth
ff73164b61
fix(sync): replace cryptic sigil counters with readable output (#86)
* fix(sync): replace cryptic sigil counters with readable output

Problem: sync summaries used unexplained sigils (`+/-/~` and `!`) that
conveyed no meaning, mixed symbol and prose formats across operations,
and `gcal push` silently swallowed failures with no aggregate counter.

Solution: replace all summary `log.info` calls with a shared
`fmt_counts` helper that formats `N label` pairs separated by ` | `,
suppresses zero counts, and falls back to "nothing to do". Add a
`failed` counter to `gcal.push` to surface errors previously only
emitted as individual warnings.

* ci: format
2026-03-06 13:26:23 -05:00
Barrett Ruth
991ac5b467
feat(sync): add opt-in remote deletion for gcal and gtasks (#85)
Problem: push/sync permanently deleted remote Google Calendar events and
Google Tasks entries whenever a local task was marked deleted, done, or
de-due'd. There was no opt-out, so a misfire could silently cause
irreversible data loss on the remote side.

Solution: add a `remote_delete` boolean to the config (default `false`).
A unified flag at `sync.remote_delete` sets the base; per-backend
overrides at `sync.gcal.remote_delete` / `sync.gtasks.remote_delete`
take precedence when non-nil. When disabled, `_extra` remote IDs are
cleared silently (unlinking) so stale IDs don't accumulate.
2026-03-06 13:12:53 -05:00
Barrett Ruth
1e2196fe2e
feat: :Pending auth subcommands + fix #61 (#84)
* fix(buffer): use `default_category` config for empty placeholder

Problem: The empty-buffer fallback hardcoded the category name `TODO`,
ignoring the user's `default_category` config value (default: `Todo`).

Solution: Read `config.get().default_category` at render time and use
that value for both the header line and `LineMeta` category field.

* fix(diff): match optional checkbox char in `parse_buffer` patterns

Problem: `parse_buffer` used `%[.%]` which requires exactly one
character between brackets, failing to parse empty `[]` checkboxes.

Solution: Change to `%[.?%]` so the character is optional, matching
`[]`, `[ ]`, `[x]`, and `[!]` uniformly.

* fix(init): add `nowait` to buffer keymap opts

Problem: Buffer-local mappings like `!` could be swallowed by Neovim's
operator-pending machinery or by global maps sharing a prefix, since
the keymap opts did not include `nowait`.

Solution: Add `nowait = true` to the shared `opts` table used for all
buffer-local mappings in `_setup_buf_mappings`.

* feat(init): allow `:Pending done` with no args to use cursor line

Problem: `:Pending done` required an explicit task ID, making it
awkward to mark the current task done while inside the pending buffer.

Solution: When called with no ID, `M.done()` reads the cursor row from
`buffer.meta()` to resolve the task ID, erroring if the cursor is not
on a saved task line.

* fix(views): populate `priority` field in `LineMeta`

Problem: Both `category_view` and `priority_view` omitted `priority`
from the `LineMeta` they produced. `apply_extmarks` checks `m.priority`
to decide whether to render the priority icon, so it was always nil,
causing the `[ ]` pending-icon overlay to replace the `[!]` buffer text.

Solution: Add `priority = task.priority` to both LineMeta constructors.

* fix(buffer): keep `_meta` in sync when `open_line` inserts a new line

Problem: `open_line` inserted a buffer line without updating `_meta`,
leaving the entry at that row pointing to the task that was shifted
down. Pressing `<CR>` (toggle_complete) would read the stale meta,
find a real task ID, toggle it, and re-render — destroying the unsaved
new line.

Solution: Insert a `{ type = 'blank' }` sentinel into `_meta` at the
new line's position so buffer-local actions see no task there.

* fix(buffer): use task sentinel in `open_line` for better unsaved-task errors

* feat(init): warn on dirty buffer before store-dependent actions

Problem: `toggle_complete`, `toggle_priority`, `prompt_date`, and
`done` (no-args) all read from `buffer.meta()` which is stale whenever
the buffer has unsaved edits, leading to silent no-ops or acting on the
wrong task.

Solution: Add a `require_saved()` guard that emits a `log.warn` and
returns false when the buffer is modified. Each store-dependent action
calls it before touching meta or the store.

* fix(init): guard `view`, `undo`, and `filter` against dirty buffer

Problem: `toggle_view`, `undo_write`, and `filter` all call
`buffer.render()` which rewrites the buffer from the store, silently
discarding any unsaved edits. The previous `require_saved()` change
missed these three entry points.

Solution: Add `require_saved()` to the `view` and `filter` keymap
lambdas and to `M.undo_write()`. Also guard `M.filter()` directly so
`:Pending filter` from the command line is covered too.

* fix(init): improve dirty-buffer warning message

* fix(init): tighten dirty-buffer warning message

* feat(oauth): add `OAuthClient:clear_tokens()` method

Problem: no way to wipe just the token file while keeping credentials
intact — `_wipe()` removed both.

Solution: add `clear_tokens()` that removes only the token file.

* fix(sync): warn instead of auto-reauth when token is missing

Problem: `with_token` silently triggered an OAuth browser flow when no
tokens existed, with no user-facing explanation.

Solution: replace the auto-reauth branch with a `log.warn` directing
the user to run `:Pending auth`.

* feat(init): add `clear` and `reset` actions to `:Pending auth`

Problem: no CLI path existed to wipe stale tokens or reset credentials,
and the `vim.ui.select` backend picker was misleading given shared tokens.

Solution: accept an args string in `M.auth()`, dispatching `clear` to
`clear_tokens()`, `reset` to `_wipe()`, and bare backend names to the
existing auth flow. Remove the picker.

* feat(plugin): add tab completion for `:Pending auth` subcommands

`:Pending auth <Tab>` completes `gcal gtasks clear reset`;
`:Pending auth <backend> <Tab>` completes `clear reset`.
2026-03-06 12:36:47 -05:00
Barrett Ruth
2929b4d8fa
feat: warn on dirty buffer before store-dependent actions (#83)
* fix(buffer): use `default_category` config for empty placeholder

Problem: The empty-buffer fallback hardcoded the category name `TODO`,
ignoring the user's `default_category` config value (default: `Todo`).

Solution: Read `config.get().default_category` at render time and use
that value for both the header line and `LineMeta` category field.

* fix(diff): match optional checkbox char in `parse_buffer` patterns

Problem: `parse_buffer` used `%[.%]` which requires exactly one
character between brackets, failing to parse empty `[]` checkboxes.

Solution: Change to `%[.?%]` so the character is optional, matching
`[]`, `[ ]`, `[x]`, and `[!]` uniformly.

* fix(init): add `nowait` to buffer keymap opts

Problem: Buffer-local mappings like `!` could be swallowed by Neovim's
operator-pending machinery or by global maps sharing a prefix, since
the keymap opts did not include `nowait`.

Solution: Add `nowait = true` to the shared `opts` table used for all
buffer-local mappings in `_setup_buf_mappings`.

* feat(init): allow `:Pending done` with no args to use cursor line

Problem: `:Pending done` required an explicit task ID, making it
awkward to mark the current task done while inside the pending buffer.

Solution: When called with no ID, `M.done()` reads the cursor row from
`buffer.meta()` to resolve the task ID, erroring if the cursor is not
on a saved task line.

* fix(views): populate `priority` field in `LineMeta`

Problem: Both `category_view` and `priority_view` omitted `priority`
from the `LineMeta` they produced. `apply_extmarks` checks `m.priority`
to decide whether to render the priority icon, so it was always nil,
causing the `[ ]` pending-icon overlay to replace the `[!]` buffer text.

Solution: Add `priority = task.priority` to both LineMeta constructors.

* fix(buffer): keep `_meta` in sync when `open_line` inserts a new line

Problem: `open_line` inserted a buffer line without updating `_meta`,
leaving the entry at that row pointing to the task that was shifted
down. Pressing `<CR>` (toggle_complete) would read the stale meta,
find a real task ID, toggle it, and re-render — destroying the unsaved
new line.

Solution: Insert a `{ type = 'blank' }` sentinel into `_meta` at the
new line's position so buffer-local actions see no task there.

* fix(buffer): use task sentinel in `open_line` for better unsaved-task errors

* feat(init): warn on dirty buffer before store-dependent actions

Problem: `toggle_complete`, `toggle_priority`, `prompt_date`, and
`done` (no-args) all read from `buffer.meta()` which is stale whenever
the buffer has unsaved edits, leading to silent no-ops or acting on the
wrong task.

Solution: Add a `require_saved()` guard that emits a `log.warn` and
returns false when the buffer is modified. Each store-dependent action
calls it before touching meta or the store.

* fix(init): guard `view`, `undo`, and `filter` against dirty buffer

Problem: `toggle_view`, `undo_write`, and `filter` all call
`buffer.render()` which rewrites the buffer from the store, silently
discarding any unsaved edits. The previous `require_saved()` change
missed these three entry points.

Solution: Add `require_saved()` to the `view` and `filter` keymap
lambdas and to `M.undo_write()`. Also guard `M.filter()` directly so
`:Pending filter` from the command line is covered too.

* fix(init): improve dirty-buffer warning message

* fix(init): tighten dirty-buffer warning message
2026-03-06 12:08:10 -05:00
Barrett Ruth
7ad27f6fca
fix: empty buffer placeholder and checkbox pattern fixes (#82)
* fix(buffer): use `default_category` config for empty placeholder

Problem: The empty-buffer fallback hardcoded the category name `TODO`,
ignoring the user's `default_category` config value (default: `Todo`).

Solution: Read `config.get().default_category` at render time and use
that value for both the header line and `LineMeta` category field.

* fix(diff): match optional checkbox char in `parse_buffer` patterns

Problem: `parse_buffer` used `%[.%]` which requires exactly one
character between brackets, failing to parse empty `[]` checkboxes.

Solution: Change to `%[.?%]` so the character is optional, matching
`[]`, `[ ]`, `[x]`, and `[!]` uniformly.
2026-03-06 12:07:52 -05:00
Barrett Ruth
55e83644b3
feat: add \:Pending done <id>\ command (#76)
Toggles a task's done/pending status by ID from the command line,
matching the buffer \`<CR>\` behaviour including recurrence spawning.
Tab-completes active task IDs.
2026-03-05 23:56:11 -05:00
Barrett Ruth
0e64aa59f1
fix(sync): auth and health UX improvements (#75)
Problem: Failed token exchange left credential files on disk, trapping
users in a broken auth loop with no way back to setup. The `auth`
prompt used raw backend names and a terse prompt string. The `health`
action appeared in `:Pending gcal health` tab completion but silently
no-oped outside `:checkhealth`. gcal health omitted the token check
that gtasks had.

Solution: `_exchange_code` now calls `_wipe()` on both failure paths,
clearing the token and credentials files so the next `:Pending auth`
routes back through `setup()`. Prompt uses full service names and
"Authenticate with:". `health` is filtered from sync subcommand
completion and dispatch — its home is `:checkhealth pending`. gcal
health now checks for tokens.
2026-03-05 23:26:09 -05:00
Barrett Ruth
6e381c0d5f
feat(sync): diff metadata preservation, auth unification, and sync quality improvements (#74)
* feat(sync): unify Google auth under :Pending auth

Problem: users had to run `:Pending gtasks auth` and `:Pending gcal
auth` separately, producing two token files and two browser consents
for the same Google account.

Solution: introduce `oauth.google_client` with combined tasks +
calendar scopes and a single `google_tokens.json`. Remove per-backend
`auth`/`setup` from `gcal` and `gtasks`; add top-level `:Pending auth`
that prompts with `vim.ui.select` and delegates to the shared client's
`setup()` or `auth()` based on credential availability.

* docs: update vimdoc for unified Google auth

Problem: `doc/pending.txt` still documented per-backend `:Pending gtasks
auth` / `:Pending gcal auth` commands and separate token files, which no
longer exist after the auth unification.

Solution: add `:Pending auth` entry to COMMANDS and a new
`*pending-google-auth*` section covering the shared PKCE flow, combined
scopes, and `google_tokens.json`. Remove `auth` from gcal/gtasks action
tables and update all cross-references to use `:Pending auth`.

* ci: format

* feat(sync): selective push, remote deletion detection, and gcal fix

Problem: `push_pass` updated all remote-linked tasks unconditionally,
causing unnecessary API calls and potential clobbering of remote edits
made between syncs. `pull`/`sync` never noticed when a task disappeared
from remote. `update_event` omitted `transparency` that `create_event`
set. Failure counts were absent from sync log summaries.

Solution: Introduce `_gtasks_synced_at` in `_extra` — stamped after
every successful push/pull create or update — so `push_pass` skips
tasks unchanged since last sync. Add `detect_remote_deletions` to
unlink local tasks whose remote entry disappeared from a successfully
fetched list. Surface failures as `!N` in all sync logs and
`unlinked: N` for pull/sync. Add `transparency = 'transparent'` to
`update_event`. Cover new behaviour with 7 tests in `gtasks_spec.lua`.

* ci: formt
2026-03-05 22:40:19 -05:00
Barrett Ruth
ad59e894c7
feat(sync): unify Google auth under :Pending auth (#72)
* feat(sync): unify Google auth under :Pending auth

Problem: users had to run `:Pending gtasks auth` and `:Pending gcal
auth` separately, producing two token files and two browser consents
for the same Google account.

Solution: introduce `oauth.google_client` with combined tasks +
calendar scopes and a single `google_tokens.json`. Remove per-backend
`auth`/`setup` from `gcal` and `gtasks`; add top-level `:Pending auth`
that prompts with `vim.ui.select` and delegates to the shared client's
`setup()` or `auth()` based on credential availability.

* docs: update vimdoc for unified Google auth

Problem: `doc/pending.txt` still documented per-backend `:Pending gtasks
auth` / `:Pending gcal auth` commands and separate token files, which no
longer exist after the auth unification.

Solution: add `:Pending auth` entry to COMMANDS and a new
`*pending-google-auth*` section covering the shared PKCE flow, combined
scopes, and `google_tokens.json`. Remove `auth` from gcal/gtasks action
tables and update all cross-references to use `:Pending auth`.

* ci: format
2026-03-05 21:08:22 -05:00
Barrett Ruth
87d8bf0896
feat(sync): credentials setup, auth continuation, and error surfacing (#71)
* feat(sync): add `setup` command to configure credentials interactively

Problem: users had to manually create a JSON credentials file at the
correct path before authenticating, with no guidance from the plugin.

Solution: add `OAuthClient:setup()` that prompts for client ID and
secret via `vim.ui.input`, writes to the shared
`google_credentials.json`, then immediately starts the OAuth flow.
Expose as `:Pending {gtasks,gcal} setup`. Also extend
`resolve_credentials()` to fall back to a shared `google_credentials.json`
so one file covers both backends.

* fix(sync): improve `setup` input loop with validation and masking

Problem: `setup()` used async `vim.ui.input` for both prompts, causing
newline and re-prompt issues when validation failed. The secret was also
echoed in plain text.

Solution: switch to synchronous `vim.fn.input` / `vim.fn.inputsecret`
loops with `vim.cmd.redraw()` + `nvim_echo` for inline error display and
re-prompting. Validate client ID format and `GOCSPX-` secret prefix
before saving.

* fix(oauth): fix `ipairs` nil truncation in `resolve_credentials` and add file-path setup option

Problem: `resolve_credentials` built `cred_paths` with a potentially nil
first element (`credentials_path`), causing `ipairs` to stop immediately
and always fall through to bundled placeholder credentials.

Solution: build `cred_paths` without nil entries using `table.insert`.
Also add a `2. Load from JSON file path` option to `setup()` via
`vim.fn.inputlist`, with `vim.fn.expand` for `~`/`$HOME` support and
the `installed` wrapper unwrap.

* doc: cleanup

* ci: format

* fix(sync): surface auth failures and detect missing credentials

Problem: three silent failure paths remained in the sync auth flow —
`with_token` gave no feedback when auth was cancelled or failed,
`get_access_token` logged a generic message on refresh failure, and
`auth()` opened a browser with `PLACEHOLDER` credentials with no
Neovim-side error.

Solution: add `log.error` in `with_token` when `get_access_token`
returns nil after auth, improve the refresh-failure message to name
the backend and hint at re-auth, and guard `auth()` with a pre-flight
check that errors immediately when bundled placeholder credentials are
detected.
2026-03-05 18:58:14 -05:00
Barrett Ruth
f78f8e42fa
feat(sync): interactive setup, auth continuation, and credential resolution fixes (#70)
* feat(sync): add `setup` command to configure credentials interactively

Problem: users had to manually create a JSON credentials file at the
correct path before authenticating, with no guidance from the plugin.

Solution: add `OAuthClient:setup()` that prompts for client ID and
secret via `vim.ui.input`, writes to the shared
`google_credentials.json`, then immediately starts the OAuth flow.
Expose as `:Pending {gtasks,gcal} setup`. Also extend
`resolve_credentials()` to fall back to a shared `google_credentials.json`
so one file covers both backends.

* fix(sync): improve `setup` input loop with validation and masking

Problem: `setup()` used async `vim.ui.input` for both prompts, causing
newline and re-prompt issues when validation failed. The secret was also
echoed in plain text.

Solution: switch to synchronous `vim.fn.input` / `vim.fn.inputsecret`
loops with `vim.cmd.redraw()` + `nvim_echo` for inline error display and
re-prompting. Validate client ID format and `GOCSPX-` secret prefix
before saving.

* fix(oauth): fix `ipairs` nil truncation in `resolve_credentials` and add file-path setup option

Problem: `resolve_credentials` built `cred_paths` with a potentially nil
first element (`credentials_path`), causing `ipairs` to stop immediately
and always fall through to bundled placeholder credentials.

Solution: build `cred_paths` without nil entries using `table.insert`.
Also add a `2. Load from JSON file path` option to `setup()` via
`vim.fn.inputlist`, with `vim.fn.expand` for `~`/`$HOME` support and
the `installed` wrapper unwrap.

* doc: cleanup

* ci: format
2026-03-05 15:29:32 -05:00
Barrett Ruth
0163941a2b
fix(sync): trigger auth then resume operation when not authenticated (#69)
* fix(sync): trigger auth then resume operation when not authenticated

Problem: `get_access_token()` called `auth()` then immediately tried to
load tokens, but `auth()` is async (TCP server + browser redirect), so
tokens were never present at that point. All sync operations silently
aborted when unauthenticated.

Solution: Remove the inline auth attempt from `get_access_token()` and
add an `on_complete` callback to `auth()` / `_exchange_code()`. Add a
`with_token(callback)` helper in `gtasks.lua` and `gcal.lua` that
triggers auth with the sync operation as the continuation, so
`push`/`pull`/`sync` resume automatically after the OAuth flow
completes.

* ci: format
2026-03-05 13:24:43 -05:00
Barrett Ruth
7fb3289b21
fix(diff): preserve due/rec when absent from buffer line (#68)
* fix(diff): preserve due/rec when absent from buffer line

Problem: `diff.apply` overwrites `task.due` and `task.recur` with `nil`
whenever those fields aren't present as inline tokens in the buffer line.
Because metadata is rendered as virtual text (never in the line text),
every description edit silently clears due dates and recurrence rules.

Solution: Only update `due`, `recur`, and `recur_mode` in the existing-
task branch when the parsed entry actually contains them (non-nil). Users
can still set/change these inline by typing `due:<date>` or `rec:<rule>`;
clearing them requires `:Pending edit <id> -due`.

* refactor: remove project-local store discovery

Problem: `store.resolve_path()` searched upward for `.pending.json`,
silently splitting task data across multiple files depending on CWD.

Solution: `resolve_path()` now always returns `config.get().data_path`.
Remove `M.init()` and the `:Pending init` command and tab-completion
entry. Remove the project-local health message.

* refactor: extract log.lua, standardise [pending.nvim]: prefix

Problem: Notifications were scattered across files using bare
`vim.notify` with inconsistent `pending.nvim: ` prefixes, and the
`debug` guard in `textobj.lua` and `init.lua` was duplicated inline.

Solution: Add `lua/pending/log.lua` with `info`, `warn`, `error`, and
`debug` functions (prefix `[pending.nvim]: `). `log.debug` only fires
when `config.debug = true` or the optional `override` param is `true`.
Replace all `vim.notify` callsites and remove inline debug guards.

* feat(parse): configurable input date formats

Problem: `due:` only accepted ISO `YYYY-MM-DD` and built-in keywords;
users expecting locale-style dates like `03/15/2026` or `15-Mar-2026`
had no way to configure alternative input formats.

Solution: Add `input_date_formats` config field (string[]). Each entry
is a strftime-like format string supporting `%Y`, `%y`, `%m`, `%d`,
`%e`, `%b`, `%B`. Formats are tried in order after built-in keywords
fail. When no year specifier is present the current or next year is
inferred. Update vimdoc and add 8 parse_spec tests.
2026-03-05 12:46:54 -05:00
Barrett Ruth
b7ce1c05ec
fix: harden sync backends and fix edit recompute (#66)
* refactor(oauth): async coroutine support, pure-Lua PKCE, server hardening

Problem: OAuth module shelled out to openssl for PKCE, used blocking
`vim.system():wait()`, had a weak `os.time()` PRNG seed, and the TCP
callback server leaked on read errors with no timeout.

Solution: Add `M.system()` coroutine wrapper and `M.async()` helper,
replace openssl with `vim.fn.sha256` + `vim.base64.encode`, seed from
`vim.uv.hrtime()`, add `close_server()` guard with 120s timeout, and
close the server on read errors.

* fix(gtasks): async operations, error notifications, buffer refresh

Problem: Sync operations blocked the editor, `push_pass` silently
dropped delete/update/create API errors, and the buffer was not
re-rendered after push/pull/sync.

Solution: Wrap `push`, `pull`, `sync` in `oauth.async()`, add
`vim.notify` for all `push_pass` failure paths, and re-render the
pending buffer after each operation.

* fix(init): edit recompute, filter predicates, sync action listing

Problem: `M.edit()` skipped `_recompute_counts()` after saving,
`compute_hidden_ids` lacked `done`/`pending` predicates, and
`run_sync` defaulted to `sync` instead of listing available actions.

Solution: Replace `s:save()` with `_save_and_notify()` in `M.edit()`,
add `done` and `pending` filter predicates, and list backend actions
when no action is specified.

* refactor(gcal): per-category calendars, async push, error notifications

Problem: gcal used a single hardcoded calendar name, ran synchronously
blocking the editor, and silently dropped some API errors.

Solution: Fetch all calendars and map categories to calendars (creating
on demand), wrap push in `oauth.async()`, notify on individual API
failures, track `_gcal_calendar_id` in `_extra`, and remove the `$`
anchor from `next_day` pattern.

* refactor: formatting fixes, config cleanup, health simplification

Problem: Formatter disagreements in `init.lua` and `gtasks.lua`,
stale `calendar` field in gcal config, and redundant health checks
for data directory existence.

Solution: Apply stylua formatting, remove `calendar` field from
`pending.GcalConfig`, drop data-dir and no-file health messages,
add `done`/`pending` to filter tab-completion candidates.

* docs: update vimdoc for sync refactor, remove demo scripts

Problem: Docs still referenced openssl dependency, defaulting to `sync`
action, and the `calendar` config field. Demo scripts used the old
singleton `store` API.

Solution: Update vimdoc and README to reflect explicit actions, per-
category calendars, and pure-Lua PKCE. Remove stale demo scripts and
update sync specs to match new behavior.

* fix(types): correct LuaLS annotations in oauth and gcal
2026-03-05 11:50:13 -05:00
Barrett Ruth
e0e3af6787
Google Tasks sync + shared OAuth module (#60)
* feat(gtasks): add Google Tasks bidirectional sync

Problem: pending.nvim only supported one-way push to Google Calendar.
Users who use Google Tasks had no way to sync tasks bidirectionally.

Solution: add `lua/pending/sync/gtasks.lua` backend with OAuth PKCE
auth, push/pull/sync actions, and field mapping between pending tasks
and Google Tasks (category↔tasklist, `priority`/`recur` via notes).

* refactor(cli): promote sync backends to top-level subcommands

Problem: `:Pending sync gtasks auth` required an extra `sync` keyword
that added no value and made the command unnecessarily verbose.

Solution: route `gtasks` and `gcal` as top-level `:Pending` subcommands
via `SYNC_BACKEND_SET` lookup. Tab completion introspects backend
modules for available actions instead of hardcoding `{ 'auth', 'sync' }`.

* docs(gtasks): document Google Tasks backend and CLI changes

Problem: vimdoc had no coverage for the gtasks backend and still
referenced the old `:Pending sync <backend>` command form.

Solution: add `:Pending-gtasks` and `:Pending-gcal` command sections
with per-action docs, update sync backend interface, and add gtasks
config example.

* ci: format

* refactor(sync): extract shared OAuth into `oauth.lua`

Problem: `gcal.lua` and `gtasks.lua` duplicated ~250 lines of identical
OAuth code (token management, PKCE flow, credential loading, curl
helpers, url encoding).

Solution: Extract a shared `OAuthClient` metatable in `oauth.lua` with
module-level utilities and instance methods. Both backends now delegate
all OAuth to `oauth.new()`. Skip `oauth` in `health.lua` backend
discovery by checking for a `name` field.

* feat(sync): ship bundled OAuth credentials

Problem: Users must manually create a Google Cloud project and place a
credentials JSON file before sync works — terrible onboarding.

Solution: Add `client_id`/`client_secret` fields to `GcalConfig` and
`GtasksConfig`. `oauth.lua` resolves credentials in three tiers: config
fields, credentials file, then bundled defaults (placeholders for now).

* docs(sync): document bundled credentials and config fields

* ci: format
2026-03-05 01:21:18 -05:00
Barrett Ruth
21628abe53
feat: Google Tasks bidirectional sync and CLI refactor (#59)
* feat(gtasks): add Google Tasks bidirectional sync

Problem: pending.nvim only supported one-way push to Google Calendar.
Users who use Google Tasks had no way to sync tasks bidirectionally.

Solution: add `lua/pending/sync/gtasks.lua` backend with OAuth PKCE
auth, push/pull/sync actions, and field mapping between pending tasks
and Google Tasks (category↔tasklist, `priority`/`recur` via notes).

* refactor(cli): promote sync backends to top-level subcommands

Problem: `:Pending sync gtasks auth` required an extra `sync` keyword
that added no value and made the command unnecessarily verbose.

Solution: route `gtasks` and `gcal` as top-level `:Pending` subcommands
via `SYNC_BACKEND_SET` lookup. Tab completion introspects backend
modules for available actions instead of hardcoding `{ 'auth', 'sync' }`.

* docs(gtasks): document Google Tasks backend and CLI changes

Problem: vimdoc had no coverage for the gtasks backend and still
referenced the old `:Pending sync <backend>` command form.

Solution: add `:Pending-gtasks` and `:Pending-gcal` command sections
with per-action docs, update sync backend interface, and add gtasks
config example.

* ci: format
2026-03-05 01:01:29 -05:00
Barrett Ruth
3e8fd0a6a3
refactor(icons): ascii defaults, checkbox overlays, and cleanup (#57)
* docs: remove unnecessary mini.ai recipe from vimdoc

Problem: the `*pending-mini-ai*` section assumed mini.ai intercepts
buffer-local `at`/`it`/`aC`/`iC` mappings, requiring a manual
`vim.b.miniai_config` workaround.

Solution: remove the section. Neovim's keymap resolver already
prioritizes longer buffer-local mappings over mini.ai's global
`a`/`i` handlers — no recipe needed.

* refactor(icons): unify category/header icon and use checkbox overlays

Problem: `header` and `category` were separate icons for the same
concept. The icon overlay replaced `[ ]` with a bare character,
hiding the markdown checkbox syntax. Header format `## ` produced
a double-space with single-char icons.

Solution: merge `header` into `category` (one icon for both header
lines and EOL labels). Overlay renders `[icon]` preserving bracket
syntax. Change header line format from `## ` to `# ` so the
2-char overlay (`# `) maps cleanly.

* ci: remove empty `assets/` placeholder
2026-03-04 18:44:41 -05:00
Barrett Ruth
ee8b660f7c
ci: fix local script (#56)
* refactor(config): default icons to ascii

Problem: default icons used unicode characters (○, ✓, ●, ▸, ·, ↺)
which render poorly in some terminals and font configurations.

Solution: replace defaults with ascii equivalents (-, x, !, >, ., ~).
Users can still override to unicode or nerd font icons via config.

* ci: ignore library type checking

* ci: cleanup ci script
2026-03-04 17:52:25 -05:00
Barrett Ruth
7718ebed42
refactor(config): default icons to ascii (#55)
* refactor(config): default icons to ascii

Problem: default icons used unicode characters (○, ✓, ●, ▸, ·, ↺)
which render poorly in some terminals and font configurations.

Solution: replace defaults with ascii equivalents (-, x, !, >, ., ~).
Users can still override to unicode or nerd font icons via config.

* ci: ignore library type checking
2026-03-04 17:49:30 -05:00
627100eb8c ci: scripts & format 2026-03-04 14:18:46 -05:00
51508285ac ci: nix 2026-03-04 14:07:04 -05:00
Barrett Ruth
a24521ee4e
feat(filter): wire F key and <Plug>(pending-filter) mapping (#53)
* refactor(config): remove legacy gcal top-level config key

Problem: the gcal migration shim silently accepted vim.g.pending = { gcal
= {...} } and copied it to sync.gcal, adding complexity and a deprecated
API surface.

Solution: remove the migration block in config.get(), drop the cfg.gcal
fallback in gcal_config(), delete the two migration tests, and clean up
the vimdoc references. Callers must now use sync.gcal directly.

* ci: fix

* fix(spec): remove duplicate buffer require in complete_spec

* docs(pending): reorganize vimdoc and fix incorrect defaults

Problem: sections were out of logical order — inline metadata appeared
before commands, GCal before its own backend framework, store resolution
duplicated and buried after health check. Two defaults were wrong:
default_category documented as 'Inbox' (should be 'Todo') and the gcal
calendar example used 'Tasks' (should be 'Pendings').

Solution: reorder all 21 sections into onboarding-first flow, add a
CONTENTS table with hyperlinks, fix both incorrect defaults in every
location they appeared, and remove the duplicate STORE RESOLUTION
section.

* feat(filter): wire F key and <Plug>(pending-filter) mapping

Problem: the filter predicate logic, diff guard, _on_write handling,
:Pending filter command, and filter_spec were already implemented, but
there was no buffer-local key to invoke filtering interactively.

Solution: add filter = 'F' to keymaps config and defaults, wire the
filter action in _setup_buf_mappings via vim.ui.input, add
<Plug>(pending-filter), and update the vimdoc (mappings table, Plug
section, config example, and FILTERS section).
2026-02-26 23:25:39 -05:00
Barrett Ruth
e0b192a88a
docs(pending): reorganize vimdoc and fix incorrect defaults (#52)
* refactor(config): remove legacy gcal top-level config key

Problem: the gcal migration shim silently accepted vim.g.pending = { gcal
= {...} } and copied it to sync.gcal, adding complexity and a deprecated
API surface.

Solution: remove the migration block in config.get(), drop the cfg.gcal
fallback in gcal_config(), delete the two migration tests, and clean up
the vimdoc references. Callers must now use sync.gcal directly.

* ci: fix

* fix(spec): remove duplicate buffer require in complete_spec

* docs(pending): reorganize vimdoc and fix incorrect defaults

Problem: sections were out of logical order — inline metadata appeared
before commands, GCal before its own backend framework, store resolution
duplicated and buried after health check. Two defaults were wrong:
default_category documented as 'Inbox' (should be 'Todo') and the gcal
calendar example used 'Tasks' (should be 'Pendings').

Solution: reorder all 21 sections into onboarding-first flow, add a
CONTENTS table with hyperlinks, fix both incorrect defaults in every
location they appeared, and remove the duplicate STORE RESOLUTION
section.
2026-02-26 23:09:05 -05:00
Barrett Ruth
4612960b9a
refactor(config): remove legacy gcal top-level config key (#51)
* refactor(config): remove legacy gcal top-level config key

Problem: the gcal migration shim silently accepted vim.g.pending = { gcal
= {...} } and copied it to sync.gcal, adding complexity and a deprecated
API surface.

Solution: remove the migration block in config.get(), drop the cfg.gcal
fallback in gcal_config(), delete the two migration tests, and clean up
the vimdoc references. Callers must now use sync.gcal directly.

* ci: fix

* fix(spec): remove duplicate buffer require in complete_spec
2026-02-26 22:53:51 -05:00
3ee26112a6 docs(pending): document :Pending init and store resolution
Add *pending-store-resolution* section explaining upward .pending.json
discovery and fallback to the global data_path. Document :Pending init
under COMMANDS. Add a cross-reference from the data_path config field.
2026-02-26 22:49:11 -05:00
59479ddb0d test: update all specs for Store instance API
Problem: every spec used the old singleton API (store.unload(),
store.load(), store.add(), etc.) and diff.apply(lines, hidden).

Solution: lower-level specs (store, diff, views, complete, file) use
s = store.new(path); s:load() directly. Higher-level specs (archive,
edit, filter, status, sync) reset package.loaded['pending'] in
before_each and use pending.store() to access the live instance.
diff.apply calls updated to diff.apply(lines, s, hidden_ids).
2026-02-26 22:49:11 -05:00
Barrett Ruth
8c90d0ddd1
refactor: remove file token feature (#50)
* refactor: remove file token feature

Problem: The file metadata token (file:<path>:<line>) was implemented
but is no longer wanted.

Solution: Remove all traces — parse.lua token parsing, diff.lua
reconciliation, views.lua LineMeta field, buffer.lua virtual text and
PendingFile highlight, complete.lua omnifunc trigger, init.lua
goto_file/add_here functions and -file edit token, plugin keymaps
<Plug>(pending-goto-file) and <Plug>(pending-add-here), config.lua
goto_file keymap field, vimdoc FILE TOKEN section, and
spec/file_spec.lua.

* ci: format
2026-02-26 22:41:38 -05:00
Barrett Ruth
0e0568769d
refactor: organize tests and dry (#49)
* refactor(store): convert singleton to Store.new() factory

Problem: store.lua used module-level _data singleton, making
project-local stores impossible and creating hidden global state.

Solution: introduce Store metatable with all operations as instance
methods. M.new(path) constructs an instance; M.resolve_path()
searches upward for .pending.json and falls back to
config.get().data_path. Singleton module API is removed.

* refactor(diff): accept store instance as parameter

Problem: diff.apply called store singleton methods directly, coupling
it to global state and preventing use with project-local stores.

Solution: change signature to apply(lines, s, hidden_ids?) where s is
a pending.Store instance. All store operations now go through s.

* refactor(buffer): add set_store/store accessors, drop singleton dep

Problem: buffer.lua imported store directly and called singleton
methods, preventing it from working with per-project store instances.

Solution: add module-level _store, M.set_store(s), and M.store()
accessors. open() and render() use _store instead of the singleton.
init.lua will call buffer.set_store(s) before buffer.open().

* refactor(complete,health,sync,plugin): update callers to store instance API

Problem: complete.lua, health.lua, sync/gcal.lua, and plugin/pending.lua
all called singleton store methods directly.

Solution: complete.lua uses buffer.store() for category lookups;
health.lua uses store.new(store.resolve_path()) and reports the
resolved path; gcal.lua calls require('pending').store() for task
access; plugin tab-completion creates ephemeral store instances via
store.new(store.resolve_path()). Add 'init' to the subcommands list.

* feat(init): thread Store instance through init, add :Pending init

Problem: init.lua called singleton store methods throughout, and there
was no way to create a project-local .pending.json file.

Solution: add module-level _store and private get_store() that
lazy-constructs via store.new(store.resolve_path()). Add public
M.store() accessor used by specs and sync backends. M.open() calls
buffer.set_store(get_store()) before buffer.open(). All store
callsites converted to get_store():method(). goto_file() and
add_here() derive the data directory from get_store().path.

Add M.init() which creates .pending.json in cwd and dispatches from
M.command() as ':Pending init'.

* test: update all specs for Store instance API

Problem: every spec used the old singleton API (store.unload(),
store.load(), store.add(), etc.) and diff.apply(lines, hidden).

Solution: lower-level specs (store, diff, views, complete, file) use
s = store.new(path); s:load() directly. Higher-level specs (archive,
edit, filter, status, sync) reset package.loaded['pending'] in
before_each and use pending.store() to access the live instance.
diff.apply calls updated to diff.apply(lines, s, hidden_ids).

* docs(pending): document :Pending init and store resolution

Add *pending-store-resolution* section explaining upward .pending.json
discovery and fallback to the global data_path. Document :Pending init
under COMMANDS. Add a cross-reference from the data_path config field.

* ci: format

* ci: remove unused variable
2026-02-26 20:03:42 -05:00
Barrett Ruth
64b19360b1
feat(customization): icons config, PendingTab, and demo infrastructure (#46)
* feat(config): add icons table with unicode defaults

* feat(buffer): render icon overlays from config.icons

Problem: status characters ([ ], [x], [!]) and metadata prefixes are
hardcoded literals with no user customization.

Solution: read config.icons in apply_extmarks and apply overlay
extmarks for checkboxes/headers, replace hardcoded recur ↺ with
icons.recur, and prefix due/category virt_text with configurable
icon characters.

* feat(plugin): add PendingTab command and <Plug>(pending-tab)

* docs: add icons config, PendingTab recipes, and demo infrastructure

Problem: icon customization and auto-start workflow are undocumented;
no demo asset exists for the README.

Solution: document pending.Icons in vimdoc with nerd font and ASCII
recipes, add PendingTab to commands and mappings, add open-on-startup
recipe, add demo-init.lua and demo.tape for VHS screenshot generation,
add assets/ directory, add README icons section and demo placeholder.

* ci: format
2026-02-26 19:20:29 -05:00
Barrett Ruth
1748e5caa1
feat(file-token): file: inline metadata token with gf navigation (#45)
* feat(file-token): add file: inline metadata token with gf navigation

Problem: there was no way to link a task to a specific location in a
source file, or to quickly jump from a task to the relevant code.

Solution: add a file:<path>:<line> inline token that stores a relative
file reference in task._extra.file. Virtual text renders basename:line
in a new PendingFile highlight group. A buffer-local gf mapping
(configurable via keymaps.goto_file) opens the file at the given line.
M.add_here() lets users attach the current cursor position to any task
via vim.ui.select(). M.edit() gains -file support to clear the
reference. <Plug>(pending-goto-file) and <Plug>(pending-add-here) are
exposed for custom mappings.

* test(file-token): add parse, diff, views, edit, and navigation tests

Problem: the file: token implementation had no test coverage.

Solution: add spec/file_spec.lua covering parse.body extraction,
malformed token handling, duplicate token stop-parsing, diff
reconciliation (store/update/clear/round-trip), LineMeta population
in both views, :Pending edit -file, and goto_file notify paths for
no-file and unreadable-file cases. All 292 tests pass.

* style: apply stylua formatting

* fix(types): remove empty elseif block, fix file? annotation nullability
2026-02-26 19:12:48 -05:00
Barrett Ruth
994294393c
docs(textobj): add mini.ai integration recipe to vimdoc (#44)
Problem: users with mini.ai installed find that buffer-local `at`, `it`,
`aC`, `iC` text objects never fire because mini.ai intercepts `a`/`i` as
single-key handlers in operator-pending/visual modes before Neovim's
mapping system can route them to buffer-local maps.

Solution: add a *pending-mini-ai* recipe section to the RECIPES block in
pending.txt. The recipe explains the conflict, describes mini.ai's
custom_textobjects spec (`{ from = {line,col}, to = {line,col} }`), and
shows how to wrap `textobj.inner_task_range` and `textobj.category_bounds`
(the two functions that return positional data) into the shape mini.ai
expects, registered via `vim.b.miniai_config` in a FileType autocmd. Notes
that `aC` cannot be expressed this way due to its linewise selection, and
that the built-in keymaps work fine for users without mini.ai.
2026-02-26 18:30:14 -05:00
Barrett Ruth
dcb6a4781d
feat(filter): oil-like editable filter line (#43)
* feat(filter): oil-like editable filter line with predicate dispatch

Problem: no way to narrow the pending buffer to a subset of tasks
without manual scrolling; filtered-out tasks would be silently deleted
on :w because diff.apply() marks unseen IDs as deleted.

Solution: add a FILTER: line rendered at the top of the buffer when a
filter is active. The line is editable — :w re-parses it and updates
the hidden set. diff.apply() gains a hidden_ids param that prevents
filtered-out tasks from being marked deleted. Predicates: cat:X,
overdue, today, priority (space-separated AND). :Pending filter sets
it programmatically; :Pending filter clear removes it.

* ci: format
2026-02-26 18:29:56 -05:00
Barrett Ruth
3da23c924a
feat(sync): backend interface + CLI refactor (#42)
* refactor(sync): extract backend interface, adapt gcal module

Problem: :Pending sync hardcodes Google Calendar — M.sync() does
pcall(require, 'pending.sync.gcal') and calls gcal.sync() directly.
The config has a flat gcal field. This prevents adding new sync backends
without modifying init.lua.

Solution: Define a backend interface contract (name, auth, sync, health
fields), refactor :Pending sync to dispatch via require('pending.sync.'
.. backend_name), add sync table to config with legacy gcal migration,
rename gcal.authorize to gcal.auth, add gcal.health for checkhealth,
and add tab completion for backend names and actions.

* docs(sync): update vimdoc for backend interface

Problem: Vimdoc documents :Pending sync as a bare command that pushes
to Google Calendar, with no mention of backends or the sync table config.

Solution: Update :Pending sync section to show {backend} [{action}]
syntax with examples, add SYNC BACKENDS section documenting the interface
contract, update config example to use sync.gcal, document legacy gcal
migration, and update health check description.

* test(sync): add backend dispatch tests

Problem: No test coverage for sync dispatch logic, config migration,
or gcal module interface conformance.

Solution: Add spec/sync_spec.lua with tests for: bare sync errors,
empty backend errors, unknown backend errors, unknown action errors,
default-to-sync routing, explicit sync/auth routing, legacy gcal config
migration, explicit sync.gcal precedence, and gcal module interface
fields (name, auth, sync, health).
2026-02-26 17:59:04 -05:00
Barrett Ruth
8d3d21b330
feat: :Pending edit command for CLI metadata editing (#41)
* feat: :Pending edit command for CLI metadata editing

Problem: editing task metadata (due date, category, priority,
recurrence) requires opening the buffer and editing inline. No way
to make quick metadata changes from the command line.

Solution: add :Pending edit {id} [operations...] command that applies
metadata changes by numeric task ID. Supports due:<date>, cat:<name>,
rec:<pattern>, +!, -!, -due, -cat, -rec operations with full date
vocabulary and recurrence validation. Pushes to undo stack, re-renders
the buffer if open, and provides feedback messages. Tab completion for
IDs, field names, date vocabulary, categories, and recurrence patterns.
Also fixes store.update() to properly clear fields set to vim.NIL.

* ci: formt
2026-02-26 16:34:07 -05:00
Barrett Ruth
e62e09f609
feat: statusline API, counts, and PendingStatusChanged event (#40)
Problem: no way to know about overdue or due-today tasks without
opening :Pending. No ambient awareness for statusline plugins.

Solution: add counts(), statusline(), and has_due() public API
functions backed by a module-local cache that recomputes after every
store.save() and store.load(). Fire a User PendingStatusChanged event
on every recompute. Extract is_overdue() and is_today() from duplicate
locals into parse.lua as public functions. Refactor views.lua and
init.lua to use the shared date logic. Add vimdoc API section and
integration recipes for lualine, heirline, manual statusline, startup
notification, and event-driven refresh.
2026-02-26 16:30:06 -05:00
Barrett Ruth
302bf8126f
feat: text objects and motions for the pending buffer (#39)
* feat: text objects and motions for the pending buffer

Problem: the pending buffer has action-button mappings but no Vim
grammar. You cannot dat to delete a task, cit to change a description,
or ]] to jump to the next category header.

Solution: add textobj.lua with at/it (a task / inner task), aC/iC
(a category / inner category), ]]/[[ (next/prev header), and ]t/[t
(next/prev task). All text objects work in operator-pending and visual
modes; motions work in normal, visual, and operator-pending. Mappings
are configurable via the keymaps table and exposed as <Plug> mappings.

* fix(textobj): escape Lua pattern hyphen, fix test expectations

Problem: inner_task_range used unescaped '-' in Lua patterns, which
acts as a lazy quantifier instead of matching a literal hyphen. The
metadata-stripping logic also tokenized the full line including the
prefix, so the rebuilt string could never be found after the prefix.
All test column expectations were off by one.

Solution: escape hyphens with %-, rewrite metadata stripping to
tokenize only the description portion after the prefix, and correct
all test assertions to match actual rendered column positions.

* feat(textobj): add debug mode, rename priority view buffer

Problem: the ]] motion reportedly lands one line past the header in
some environments, and ]t/[t may not override Neovim defaults. No
way to diagnose these at runtime. Also, pending://priority is a poor
buffer name for the flat ranked view.

Solution: add a debug config option (vim.g.pending = { debug = true })
that logs meta state, cursor positions, and mapping registration to
:messages at DEBUG level. Rename the buffer from pending://priority to
pending://queue. Internal view identifier stays 'priority'.

* docs: text objects, motions, debug mode, queue view rename

Problem: vimdoc had no documentation for the new text objects, motions,
debug config, or the pending://queue buffer rename.

Solution: add text object and motion tables to the mappings section,
document all eight <Plug> mappings, add debug field to the config
reference, update config example with new keymap defaults, rename
priority view references to queue throughout the vimdoc.

* fix(textobj): use correct config variable, raise log level

Problem: motion keymaps (]], [[, ]t, [t) were never set because
`config.get().debug` referenced an undefined `config` variable,
crashing _setup_buf_mappings before the motion loop. Debug logging
also used vim.log.levels.DEBUG which is filtered by default.

Solution: replace `config` with `cfg` (already in scope) and raise
both debug notify calls from DEBUG to INFO.

* ci: formt
2026-02-26 16:28:58 -05:00
Barrett Ruth
c57cc0845b
feat: time-aware due dates, persistent undo, @return audit (#33)
* fix(plugin): allow command chaining with bar separator

Problem: :Pending|only failed because the command definition lacked the
bar attribute, causing | to be consumed as an argument.

Solution: Add bar = true to nvim_create_user_command so | is treated as
a command separator, matching fugitive's :Git behavior.

* refactor(buffer): remove opinionated window options

Problem: The plugin hardcoded number, relativenumber, wrap, spell,
signcolumn, foldcolumn, and cursorline in set_win_options, overriding
user preferences with no way to opt out.

Solution: Remove all cosmetic window options. Users who want them can
set them in after/ftplugin/pending.lua. Only conceallevel,
concealcursor, and winfixheight remain as functionally required.

* feat: time-aware due dates, persistent undo, @return audit

Problem: Due dates had no time component, the undo stack was lost on
restart and stored in a separate file, and many public functions lacked
required @return annotations.

Solution: Add YYYY-MM-DDThh:mm support across parse, views, recur,
complete, and init with time-aware overdue checks. Merge the undo stack
into the task store JSON so a single file holds all state. Add @return
nil annotations to all 27 void public functions across every module.

* feat(parse): flexible time parsing for @ suffix

Problem: the @HH:MM time suffix required zero-padded 24-hour format,
forcing users to write due:tomorrow@14:00 instead of due:tomorrow@2pm.

Solution: add normalize_time() that accepts bare hours (9, 14),
H:MM (9:30), am/pm (2pm, 9:30am, 12am), and existing HH:MM format,
normalizing all to canonical HH:MM on save.

* feat(complete): add info descriptions to omnifunc items

Problem: completion menu items had no description, making it hard to
distinguish between similar entries like date shorthands and recurrence
patterns.

Solution: return { word, info } tables from date_completions() and
recur_completions(), surfacing human-readable descriptions in the
completion popup.

* ci: format
2026-02-25 20:37:50 -05:00
Barrett Ruth
72dbf037c7
refactor(buffer): remove opinionated window options, fix close (#32)
* fix(plugin): allow command chaining with bar separator

Problem: :Pending|only failed because the command definition lacked the
bar attribute, causing | to be consumed as an argument.

Solution: Add bar = true to nvim_create_user_command so | is treated as
a command separator, matching fugitive's :Git behavior.

* refactor(buffer): remove opinionated window options

Problem: The plugin hardcoded number, relativenumber, wrap, spell,
signcolumn, foldcolumn, and cursorline in set_win_options, overriding
user preferences with no way to opt out.

Solution: Remove all cosmetic window options. Users who want them can
set them in after/ftplugin/pending.lua. Only conceallevel,
concealcursor, and winfixheight remain as functionally required.
2026-02-25 17:34:40 -05:00
Barrett Ruth
b76c680e1f
feat: fix q on close last window (#31)
* fix(plugin): allow command chaining with bar separator

Problem: :Pending|only failed because the command definition lacked the
bar attribute, causing | to be consumed as an argument.

Solution: Add bar = true to nvim_create_user_command so | is treated as
a command separator, matching fugitive's :Git behavior.

* fix: last window
2026-02-25 13:45:42 -05:00
Barrett Ruth
379e281ecd
fix(plugin): allow command chaining with bar separator (#29)
Problem: :Pending|only failed because the command definition lacked the
bar attribute, causing | to be consumed as an argument.

Solution: Add bar = true to nvim_create_user_command so | is treated as
a command separator, matching fugitive's :Git behavior.
2026-02-25 13:40:36 -05:00
Barrett Ruth
7d93c4bb45
feat: omnifunc completion, recurring tasks, expanded date syntax (#27)
* feat(config): add recur_syntax and someday_date fields

Problem: the plugin needs configuration for the recurrence token name
and the sentinel date used by the `later`/`someday` named dates.

Solution: add `recur_syntax` (default 'rec') and `someday_date`
(default '9999-12-30') to pending.Config and the defaults table.

* feat(parse): expand date vocabulary with named dates

Problem: the date input only supports today, tomorrow, +Nd, and
weekday names, lacking relative offsets like weeks/months, period
boundaries, ordinals, month names, and backdating.

Solution: add yesterday, eod, sow/eow, som/eom, soq/eoq, soy/eoy,
+Nw, +Nm, -Nd, -Nw, ordinals (1st-31st), month names (jan-dec),
and later/someday to resolve_date(). Add tests for all new tokens.

* feat(recur): add recurrence parsing and next-date computation

Problem: the plugin has no concept of recurring tasks, which is
needed for habits and repeating deadlines.

Solution: add recur.lua with parse(), validate(), next_due(),
to_rrule(), and shorthand_list(). Supports named shorthands (daily,
weekdays, weekly, etc.), interval notation (Nd, Nw, Nm, Ny), raw
RRULE passthrough, and ! prefix for completion-based mode. Includes
day-clamping for month/year advancement.

* feat(store): add recur and recur_mode task fields

Problem: the task schema has no fields for storing recurrence rules.

Solution: add recur and recur_mode to the Task class, known_fields,
task_to_table, table_to_task, and the add() signature.

* feat(parse): add rec: inline token parsing

Problem: the buffer parser does not recognize recurrence tokens,
so users cannot set recurrence rules inline.

Solution: add recur_key() helper and rec: token parsing in body()
and command_add(), with ! prefix handling for completion-based mode
and validation via recur.validate().

* feat(diff): propagate recurrence through buffer reconciliation

Problem: the diff layer does not extract or apply recurrence fields,
so rec: tokens written in the buffer are silently ignored on :w.

Solution: add rec and rec_mode to ParsedEntry, extract them in
parse_buffer(), and pass them through create and update paths in
apply().

* feat(init): spawn next task on recurring task completion

Problem: completing a recurring task does not create the next
occurrence, and :Pending add does not pass recurrence fields.

Solution: in toggle_complete(), detect recurrence and spawn a new
pending task with the next due date. Wire rec/rec_mode through the
add() command path.

* feat(views): add recurrence to LineMeta

Problem: LineMeta does not carry recurrence info, so the buffer
layer cannot display recurrence indicators.

Solution: add recur field to LineMeta and populate it in both
category_view() and priority_view().

* feat(buffer): add PendingRecur highlight and recurrence virtual text

Problem: recurring tasks have no visual indicator in the buffer,
and the extmark logic uses a rigid if/elseif chain that does not
compose well with additional virtual text fields.

Solution: add PendingRecur highlight group linking to DiagnosticInfo.
Refactor apply_extmarks() to build virtual text parts dynamically,
appending category, recurrence indicator, and due date as separate
composable segments. Set omnifunc on the pending buffer.

* feat(complete): add omnifunc for cat:, due:, and rec: tokens

Problem: the pending buffer has no completion source, requiring
users to type metadata tokens from memory.

Solution: add complete.lua with an omnifunc that completes cat:
tokens from existing categories, due: tokens from the named date
vocabulary, and rec: tokens from recurrence shorthands.

* docs: document recurrence, expanded dates, omnifunc, new config

Problem: the vimdoc does not cover recurrence, expanded date syntax,
omnifunc completion, or the new config fields.

Solution: add DATE INPUT and RECURRENCE sections, update INLINE
METADATA, COMMANDS, CONFIGURATION, HIGHLIGHT GROUPS, HEALTH CHECK,
and DATA FORMAT. Expand the help popup with recurrence patterns and
new date tokens. Add recurrence validation to healthcheck.

* ci: fix

* fix(recur): resolve LuaLS type errors

Problem: LuaLS reported undefined-field for `_raw` on RecurSpec and
param-type-mismatch for `last_day.day` in `advance_date` because
`osdate.day` infers as `string|integer`.

Solution: Add `_raw` to the RecurSpec class annotation and cast
`last_day.day` to integer in both `math.min` call sites.

* refactor(init): remove help popup, use config-driven keymaps

Problem: Buffer-local keymaps were hardcoded with no way for users to
customize them. The g? help popup duplicated information already in the
vimdoc.

Solution: Remove show_help() and the g? mapping. Refactor
_setup_buf_mappings to read from cfg.keymaps, letting users override or
disable any buffer-local binding via vim.g.pending.

* feat(config): add keymaps table for buffer-local bindings

Problem: Users had no way to customize or disable buffer-local key
bindings in the pending buffer.

Solution: Add a pending.Keymaps class and keymaps field to
pending.Config with defaults for all eight buffer actions. Setting any
key to false disables that binding.

* feat(plugin): add Plug mappings for all buffer actions

Problem: Only five of nine buffer actions had <Plug> mappings, so users
could not bind close, undo, open-line, or open-line-above globally.

Solution: Add <Plug>(pending-close), <Plug>(pending-undo),
<Plug>(pending-open-line), and <Plug>(pending-open-line-above).

* docs: update mappings and config for keymaps and new Plug entries

Problem: Vimdoc still listed g? help popup, lacked documentation for
the four new <Plug> mappings, and had no keymaps config section.

Solution: Remove g? from mappings table, document all nine <Plug>
mappings, add keymaps table to the config example and field reference,
and note that buffer-local keys are configurable.
2026-02-25 13:27:52 -05:00
Barrett Ruth
6911c091f6
doc: minify readme (#24)
* doc: minify readme

* ci: format
2026-02-25 09:40:06 -05:00
41 changed files with 8439 additions and 1319 deletions

View file

@ -1,4 +1,4 @@
title: 'Q&A' title: "Q&A"
labels: [] labels: []
body: body:
- type: markdown - type: markdown

View file

@ -1,14 +1,13 @@
name: Bug Report name: Bug Report
description: Report a bug description: Report a bug
title: 'bug: ' title: "bug: "
labels: [bug] labels: [bug]
body: body:
- type: checkboxes - type: checkboxes
attributes: attributes:
label: Prerequisites label: Prerequisites
options: options:
- label: - label: I have searched [existing
I have searched [existing
issues](https://github.com/barrettruth/pending.nvim/issues) issues](https://github.com/barrettruth/pending.nvim/issues)
required: true required: true
- label: I have updated to the latest version - label: I have updated to the latest version
@ -16,16 +15,16 @@ body:
- type: textarea - type: textarea
attributes: attributes:
label: 'Neovim version' label: "Neovim version"
description: 'Output of `nvim --version`' description: "Output of `nvim --version`"
render: text render: text
validations: validations:
required: true required: true
- type: input - type: input
attributes: attributes:
label: 'Operating system' label: "Operating system"
placeholder: 'e.g. Arch Linux, macOS 15, Ubuntu 24.04' placeholder: "e.g. Arch Linux, macOS 15, Ubuntu 24.04"
validations: validations:
required: true required: true
@ -49,8 +48,8 @@ body:
- type: textarea - type: textarea
attributes: attributes:
label: 'Health check' label: "Health check"
description: 'Output of `:checkhealth task`' description: "Output of `:checkhealth task`"
render: text render: text
- type: textarea - type: textarea

View file

@ -1,14 +1,13 @@
name: Feature Request name: Feature Request
description: Suggest a feature description: Suggest a feature
title: 'feat: ' title: "feat: "
labels: [enhancement] labels: [enhancement]
body: body:
- type: checkboxes - type: checkboxes
attributes: attributes:
label: Prerequisites label: Prerequisites
options: options:
- label: - label: I have searched [existing
I have searched [existing
issues](https://github.com/barrettruth/pending.nvim/issues) issues](https://github.com/barrettruth/pending.nvim/issues)
required: true required: true

View file

@ -3,7 +3,7 @@ name: luarocks
on: on:
push: push:
tags: tags:
- 'v*' - "v*"
jobs: jobs:
quality: quality:

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
doc/tags doc/tags
*.log *.log
minimal_init.lua
.*cache* .*cache*
CLAUDE.md CLAUDE.md

View file

@ -2,7 +2,14 @@
"runtime.version": "LuaJIT", "runtime.version": "LuaJIT",
"runtime.path": ["lua/?.lua", "lua/?/init.lua"], "runtime.path": ["lua/?.lua", "lua/?/init.lua"],
"diagnostics.globals": ["vim", "jit"], "diagnostics.globals": ["vim", "jit"],
"workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], "diagnostics.libraryFiles": "Disable",
"workspace.library": [
"$VIMRUNTIME/lua",
"${3rd}/luv/library",
"${3rd}/busted/library",
"${3rd}/luassert/library"
],
"workspace.checkThirdParty": false, "workspace.checkThirdParty": false,
"workspace.ignoreDir": [".direnv"],
"completion.callSnippet": "Replace" "completion.callSnippet": "Replace"
} }

147
README.md
View file

@ -1,146 +1,35 @@
# pending.nvim # pending.nvim
Edit tasks like text. `:w` saves them. **Edit tasks like text.**
A buffer-centric task manager for Neovim. Tasks live in a plain buffer — add Oil-like task management for todos in Neovim, inspired by
with `o`, delete with `dd`, reorder with `dd`/`p`, rename by editing. Write the [oil.nvim](https://github.com/stevearc/oil.nvim),
buffer and the diff is computed against a JSON store. No UI chrome, no floating [vim-fugitive](https://github.com/tpope/vim-fugitive)
windows, no abstractions between you and your tasks.
## How it works https://github.com/user-attachments/assets/f3898ecb-ec95-43fe-a71f-9c9f49628ba9
``` ## Requirements
School
! Read chapter 5 Feb 28
Submit homework Feb 25
Errands - Neovim 0.10+
Buy groceries Mar 01 - (Optionally) `curl` for Google Calendar and Google Task sync
Clean apartment
```
Category headers sit at column 0. Tasks are indented below them. `!` marks ## Installation
priority. Due dates appear as right-aligned virtual text. Done tasks get
strikethrough. Everything you see is editable buffer text — the IDs are
concealed, and metadata is parsed from inline syntax on save.
## Install Install with your package manager of choice or via
[luarocks](https://luarocks.org/modules/barrettruth/pending.nvim):
``` ```
luarocks install pending.nvim luarocks install pending.nvim
``` ```
**lazy.nvim:**
```lua
{ 'barrettruth/pending.nvim' }
```
Requires Neovim 0.10+. No external dependencies for local use. Google Calendar
sync requires `curl` and `openssl`.
## Usage
`:Pending` opens the task buffer. From there, it's just vim:
| Key | Action |
| --------- | ------------------------------- |
| `o` / `O` | Add a new task |
| `dd` | Delete a task (on `:w`) |
| `p` | Paste (duplicates get new IDs) |
| `:w` | Save all changes |
| `<CR>` | Toggle complete (immediate) |
| `<Tab>` | Switch category / priority view |
| `g?` | Show keybind help |
### Inline metadata
Type metadata tokens at the end of a task line before saving:
```
Buy milk due:2026-03-15 cat:Errands
```
On `:w`, the date and category are extracted. The description becomes `Buy milk`,
the due date renders as virtual text, and the task moves under the `Errands`
header.
### Quick add
```vim
:Pending add Buy groceries due:2026-03-15
:Pending add School: Submit homework
```
### Archive
```vim
:Pending archive " purge done tasks older than 30 days
:Pending archive 7 " purge done tasks older than 7 days
```
## Configuration
No `setup()` call required. Set `vim.g.pending` before the plugin loads:
```lua
vim.g.pending = {
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
default_view = 'category', -- 'category' or 'priority'
default_category = 'Inbox',
date_format = '%b %d', -- strftime format for virtual text
date_syntax = 'due', -- inline token name (e.g. 'by' for by:2026-03-15)
}
```
All fields are optional. Absent keys use the defaults shown above.
## Google Calendar sync
One-way push of tasks with due dates to a dedicated Google Calendar as all-day
events.
```lua
vim.g.pending = {
gcal = {
calendar = 'Tasks',
credentials_path = '/path/to/client_secret.json',
},
}
```
```vim
:Pending sync
```
On first run, a browser window opens for OAuth consent. The refresh token is
stored at `stdpath('data')/pending/gcal_tokens.json`. Completed or deleted tasks
have their calendar events removed. Due date changes update events in place.
## Mappings
The plugin defines `<Plug>` mappings for custom keybinds:
```lua
vim.keymap.set('n', '<leader>t', '<Plug>(pending-open)')
vim.keymap.set('n', '<leader>T', '<Plug>(pending-toggle)')
```
| Plug mapping | Action |
| -------------------------- | -------------------- |
| `<Plug>(pending-open)` | Open task buffer |
| `<Plug>(pending-toggle)` | Toggle complete |
| `<Plug>(pending-view)` | Switch view |
| `<Plug>(pending-priority)` | Toggle priority flag |
| `<Plug>(pending-date)` | Prompt for due date |
## Data format
Tasks are stored as JSON at `stdpath('data')/pending/tasks.json`. The schema is
versioned and forward-compatible — unknown fields are preserved on round-trip.
## Documentation ## Documentation
```vim ```vim
:checkhealth pending :help pending.nvim
``` ```
## Acknowledgements
- [dooing](https://github.com/atiladefreitas/dooing)
- [todo-comments.nvim](https://github.com/folke/todo-comments.nvim)
- [todotxt.nvim](https://github.com/arnarg/todotxt.nvim)

File diff suppressed because it is too large Load diff

View file

@ -13,9 +13,12 @@
... ...
}: }:
let let
forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); forEachSystem =
f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
in in
{ {
formatter = forEachSystem (pkgs: pkgs.nixfmt-tree);
devShells = forEachSystem (pkgs: { devShells = forEachSystem (pkgs: {
default = pkgs.mkShell { default = pkgs.mkShell {
packages = [ packages = [

View file

@ -1,21 +1,37 @@
local config = require('pending.config') local config = require('pending.config')
local store = require('pending.store') local log = require('pending.log')
local views = require('pending.views') local views = require('pending.views')
---@class pending.buffer ---@class pending.buffer
local M = {} local M = {}
---@type pending.Store?
local _store = nil
---@type integer? ---@type integer?
local task_bufnr = nil local task_bufnr = nil
---@type integer? ---@type integer?
local task_winid = nil local task_winid = nil
local task_ns = vim.api.nvim_create_namespace('pending') local ns_eol = vim.api.nvim_create_namespace('pending_eol')
local ns_inline = vim.api.nvim_create_namespace('pending_inline')
---@type 'category'|'priority'|nil ---@type 'category'|'priority'|nil
local current_view = nil local current_view = nil
---@type pending.LineMeta[] ---@type pending.LineMeta[]
local _meta = {} local _meta = {}
---@type table<integer, table<string, boolean>> ---@type table<integer, table<string, boolean>>
local _fold_state = {} local _fold_state = {}
---@type boolean
local _initial_fold_loaded = false
---@type string[]
local _filter_predicates = {}
---@type table<integer, true>
local _hidden_ids = {}
---@type table<integer, true>
local _dirty_rows = {}
---@type boolean
local _on_bytes_active = false
---@type boolean
local _rendering = false
---@return pending.LineMeta[] ---@return pending.LineMeta[]
function M.meta() function M.meta()
@ -37,12 +53,239 @@ function M.current_view_name()
return current_view return current_view
end end
---@param s pending.Store?
---@return nil
function M.set_store(s)
_store = s
end
---@return pending.Store?
function M.store()
return _store
end
---@return string[]
function M.filter_predicates()
return _filter_predicates
end
---@return table<integer, true>
function M.hidden_ids()
return _hidden_ids
end
---@param predicates string[]
---@param hidden table<integer, true>
---@return nil
function M.set_filter(predicates, hidden)
_filter_predicates = predicates
_hidden_ids = hidden
end
---@return nil
function M.clear_winid() function M.clear_winid()
task_winid = nil task_winid = nil
end end
---@param winid integer
---@return nil
function M.update_winid(winid)
task_winid = winid
end
---@param b? integer
---@return nil
function M.clear_marks(b)
local bufnr = b or task_bufnr
vim.api.nvim_buf_clear_namespace(bufnr, ns_eol, 0, -1)
vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, 0, -1)
end
---@param b integer
---@param row integer
---@return nil
function M.clear_inline_row(b, row)
vim.api.nvim_buf_clear_namespace(b, ns_inline, row - 1, row)
end
---@return table<integer, true>
function M.dirty_rows()
return _dirty_rows
end
---@return nil
function M.clear_dirty_rows()
_dirty_rows = {}
end
---@param bufnr integer
---@param row integer
---@param m pending.LineMeta
---@param icons table
local function apply_inline_row(bufnr, row, m, icons)
if m.type == 'filter' then
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, {
end_col = #line,
hl_group = 'PendingFilter',
})
elseif m.type == 'task' then
if m.status == 'done' then
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) or 0
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, {
end_col = #line,
hl_group = 'PendingDone',
})
end
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
local bracket_col = (line:find('%[') or 1) - 1
local icon, icon_hl
if m.status == 'done' then
icon, icon_hl = icons.done, 'PendingDone'
elseif m.priority and m.priority > 0 then
icon, icon_hl = icons.priority, 'PendingPriority'
else
icon, icon_hl = icons.pending, 'Normal'
end
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, bracket_col, {
virt_text = { { '[' .. icon .. ']', icon_hl } },
virt_text_pos = 'overlay',
priority = 100,
})
elseif m.type == 'header' then
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, {
end_col = #line,
hl_group = 'PendingHeader',
})
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, {
virt_text = { { icons.category .. ' ', 'PendingHeader' } },
virt_text_pos = 'overlay',
priority = 100,
})
end
end
---@param bufnr integer
---@return nil
function M.reapply_dirty_inline(bufnr)
if not next(_dirty_rows) then
return
end
local icons = config.get().icons
for row in pairs(_dirty_rows) do
local m = _meta[row]
if m then
vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, row - 1, row)
apply_inline_row(bufnr, row - 1, m, icons)
end
end
_dirty_rows = {}
end
---@param bufnr integer
---@return nil
function M.attach_bytes(bufnr)
if _on_bytes_active then
return
end
_on_bytes_active = true
vim.api.nvim_buf_attach(bufnr, false, {
on_bytes = function(_, buf, _, start_row, _, _, old_end_row, _, _, new_end_row, _, _)
if buf ~= task_bufnr then
_on_bytes_active = false
return true
end
if _rendering then
return
end
local delta = new_end_row - old_end_row
if delta > 0 then
for _ = 1, delta do
table.insert(_meta, start_row + 2, { type = 'task' })
end
elseif delta < 0 then
for _ = 1, -delta do
if _meta[start_row + 2] then
table.remove(_meta, start_row + 2)
end
end
end
for r = start_row + 1, start_row + 1 + math.max(0, new_end_row) do
_dirty_rows[r] = true
end
end,
})
end
---@return nil
function M.persist_folds()
log.debug(
('persist_folds: view=%s store=%s'):format(tostring(current_view), tostring(_store ~= nil))
)
if current_view ~= 'category' or not _store then
log.debug('persist_folds: early return (view or store)')
return
end
local bufnr = task_bufnr
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
log.debug('persist_folds: early return (no valid bufnr)')
return
end
local folded = {}
local seen = {}
local wins = vim.fn.win_findbuf(bufnr)
log.debug(
('persist_folds: checking %d windows for bufnr=%d, meta has %d entries'):format(
#wins,
bufnr,
#_meta
)
)
for _, winid in ipairs(wins) do
if vim.api.nvim_win_is_valid(winid) then
vim.api.nvim_win_call(winid, function()
for lnum, m in ipairs(_meta) do
if m.type == 'header' and m.category and not seen[m.category] then
local closed = vim.fn.foldclosed(lnum)
log.debug(
('persist_folds: win=%d lnum=%d cat=%s foldclosed=%d'):format(
winid,
lnum,
m.category,
closed
)
)
if closed ~= -1 then
seen[m.category] = true
table.insert(folded, m.category)
end
end
end
end)
end
end
log.debug(
('persist_folds: saving %d folded categories: %s'):format(#folded, table.concat(folded, ', '))
)
_store:set_folded_categories(folded)
end
---@return nil
function M.close() function M.close()
if task_winid and vim.api.nvim_win_is_valid(task_winid) then if not task_winid or not vim.api.nvim_win_is_valid(task_winid) then
task_winid = nil
return
end
M.persist_folds()
if _store then
_store:save()
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
@ -55,19 +298,13 @@ 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
@ -77,7 +314,7 @@ local function setup_syntax(bufnr)
vim.cmd([[ vim.cmd([[
syntax clear syntax clear
syntax match taskId /^\/\d\+\// conceal syntax match taskId /^\/\d\+\// conceal
syntax match taskHeader /^## .*$/ contains=taskId syntax match taskHeader /^# .*$/ contains=taskId
syntax match taskCheckbox /\[!\]/ contained containedin=taskLine syntax match taskCheckbox /\[!\]/ contained containedin=taskLine
syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox
]]) ]])
@ -85,6 +322,7 @@ 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
@ -114,50 +352,110 @@ function M.get_fold()
end end
end end
---@class pending.EolSegment
---@field type 'specifier'|'literal'
---@field key? 'c'|'r'|'d'
---@field text? string
---@param fmt string
---@return pending.EolSegment[]
local function parse_eol_format(fmt)
local segments = {}
local pos = 1
local len = #fmt
while pos <= len do
if fmt:sub(pos, pos) == '%' and pos + 1 <= len then
local key = fmt:sub(pos + 1, pos + 1)
if key == 'c' or key == 'r' or key == 'd' then
table.insert(segments, { type = 'specifier', key = key })
pos = pos + 2
else
table.insert(segments, { type = 'literal', text = '%' .. key })
pos = pos + 2
end
else
local next_pct = fmt:find('%%', pos + 1)
local chunk = next_pct and fmt:sub(pos, next_pct - 1) or fmt:sub(pos)
table.insert(segments, { type = 'literal', text = chunk })
pos = pos + #chunk
end
end
return segments
end
---@param segments pending.EolSegment[]
---@param m pending.LineMeta
---@param icons pending.Icons
---@return string[][]
local function build_eol_virt(segments, m, icons)
local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue'
local resolved = {}
for i, seg in ipairs(segments) do
if seg.type == 'specifier' then
local text, hl
if seg.key == 'c' and m.show_category and m.category then
text = icons.category .. ' ' .. m.category
hl = 'PendingHeader'
elseif seg.key == 'r' and m.recur then
text = icons.recur .. ' ' .. m.recur
hl = 'PendingRecur'
elseif seg.key == 'd' and m.due then
text = icons.due .. ' ' .. m.due
hl = due_hl
end
resolved[i] = text and { text = text, hl = hl, present = true }
or { present = false }
else
resolved[i] = { text = seg.text, hl = 'Normal', literal = true }
end
end
local virt_parts = {}
for i, r in ipairs(resolved) do
if r.literal then
local prev_present, next_present = false, false
for j = i - 1, 1, -1 do
if not resolved[j].literal then
prev_present = resolved[j].present
break
end
end
for j = i + 1, #resolved do
if not resolved[j].literal then
next_present = resolved[j].present
break
end
end
if prev_present and next_present then
table.insert(virt_parts, { r.text, r.hl })
end
elseif r.present then
table.insert(virt_parts, { r.text, r.hl })
end
end
return virt_parts
end
---@param bufnr integer ---@param bufnr integer
---@param line_meta pending.LineMeta[] ---@param line_meta pending.LineMeta[]
local function apply_extmarks(bufnr, line_meta) local function apply_extmarks(bufnr, line_meta)
vim.api.nvim_buf_clear_namespace(bufnr, task_ns, 0, -1) local cfg = config.get()
local icons = cfg.icons
local eol_segments = parse_eol_format(cfg.eol_format or '%c %r %d')
vim.api.nvim_buf_clear_namespace(bufnr, ns_eol, 0, -1)
vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, 0, -1)
for i, m in ipairs(line_meta) do for i, m in ipairs(line_meta) do
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 virt_parts = build_eol_virt(eol_segments, m, icons)
if m.show_category then if #virt_parts > 0 then
local virt_text vim.api.nvim_buf_set_extmark(bufnr, ns_eol, row, 0, {
if m.category and m.due then virt_text = virt_parts,
virt_text = { { m.category .. ' ', 'PendingHeader' }, { m.due, due_hl } }
elseif m.category then
virt_text = { { m.category, 'PendingHeader' } }
elseif m.due then
virt_text = { { m.due, due_hl } }
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, {
virt_text = { { m.due, due_hl } },
virt_text_pos = 'eol', virt_text_pos = 'eol',
}) })
end end
if m.status == 'done' then
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) or 0
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, col_start, {
end_col = #line,
hl_group = 'PendingDone',
})
end
elseif m.type == 'header' then
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
end_col = #line,
hl_group = 'PendingHeader',
})
end end
apply_inline_row(bufnr, row, m, icons)
end end
end end
@ -167,51 +465,114 @@ 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, 'PendingFilter', { link = 'DiagnosticWarn', default = true })
end
---@return string
function M.get_foldtext()
local folding = config.resolve_folding()
if not folding.foldtext then
return vim.fn.foldtext()
end
local line = vim.fn.getline(vim.v.foldstart)
local cat = line:match('^#%s+(.+)$') or line
local task_count = vim.v.foldend - vim.v.foldstart
local icons = config.get().icons
local result = folding.foldtext
:gsub('%%c', cat)
:gsub('%%n', tostring(task_count))
:gsub('(%d+) (%w+)s%)', function(n, word)
if n == '1' then
return n .. ' ' .. word .. ')'
end
return n .. ' ' .. word .. 's)'
end)
return icons.category .. ' ' .. result
end end
local function snapshot_folds(bufnr) local function snapshot_folds(bufnr)
if current_view ~= 'category' then if current_view ~= 'category' or not config.resolve_folding().enabled then
return return
end end
for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do
local state = {} if _fold_state[winid] == nil and _initial_fold_loaded then
vim.api.nvim_win_call(winid, function() local state = {}
for lnum, m in ipairs(_meta) do vim.api.nvim_win_call(winid, function()
if m.type == 'header' and m.category then for lnum, m in ipairs(_meta) do
if vim.fn.foldclosed(lnum) ~= -1 then if m.type == 'header' and m.category then
state[m.category] = true if vim.fn.foldclosed(lnum) ~= -1 then
state[m.category] = true
end
end end
end end
end end)
end) _fold_state[winid] = state
_fold_state[winid] = state end
end end
end end
local function restore_folds(bufnr) local function restore_folds(bufnr)
if current_view ~= 'category' then log.debug(
('restore_folds: view=%s folding_enabled=%s'):format(
tostring(current_view),
tostring(config.resolve_folding().enabled)
)
)
if current_view ~= 'category' or not config.resolve_folding().enabled then
return return
end end
for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do
local state = _fold_state[winid] local state = _fold_state[winid]
_fold_state[winid] = nil
log.debug(
('restore_folds: win=%d has_fold_state=%s initial_loaded=%s has_store=%s'):format(
winid,
tostring(state ~= nil),
tostring(_initial_fold_loaded),
tostring(_store ~= nil)
)
)
if not state and not _initial_fold_loaded and _store then
_initial_fold_loaded = true
local cats = _store:get_folded_categories()
log.debug(
('restore_folds: loaded %d categories from store: %s'):format(
#cats,
table.concat(cats, ', ')
)
)
if #cats > 0 then
state = {}
for _, cat in ipairs(cats) do
state[cat] = true
end
end
end
if state and next(state) ~= nil then if state and next(state) ~= nil then
local applying = {}
for k in pairs(state) do
table.insert(applying, k)
end
log.debug(('restore_folds: applying folds for: %s'):format(table.concat(applying, ', ')))
vim.api.nvim_win_call(winid, function() vim.api.nvim_win_call(winid, function()
vim.cmd('normal! zx') vim.cmd('normal! zx')
local saved = vim.api.nvim_win_get_cursor(0) local saved = vim.api.nvim_win_get_cursor(0)
for lnum, m in ipairs(_meta) do for lnum, m in ipairs(_meta) do
if m.type == 'header' and m.category and state[m.category] then if m.type == 'header' and m.category and state[m.category] then
log.debug(('restore_folds: folding lnum=%d cat=%s'):format(lnum, m.category))
vim.api.nvim_win_set_cursor(0, { lnum, 0 }) vim.api.nvim_win_set_cursor(0, { lnum, 0 })
vim.cmd('normal! zc') vim.cmd('normal! zc')
end end
end end
vim.api.nvim_win_set_cursor(0, saved) vim.api.nvim_win_set_cursor(0, saved)
end) end)
_fold_state[winid] = nil
end end
end end
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
@ -219,8 +580,15 @@ function M.render(bufnr)
end end
current_view = current_view or config.get().default_view current_view = current_view or config.get().default_view
vim.api.nvim_buf_set_name(bufnr, 'pending://' .. current_view) local view_label = current_view == 'priority' and 'queue' or current_view
local tasks = store.active_tasks() vim.api.nvim_buf_set_name(bufnr, 'pending://' .. view_label)
local all_tasks = _store and _store:active_tasks() or {}
local tasks = {}
for _, task in ipairs(all_tasks) do
if not _hidden_ids[task.id] then
table.insert(tasks, task)
end
end
local lines, line_meta local lines, line_meta
if current_view == 'priority' then if current_view == 'priority' then
@ -229,25 +597,45 @@ function M.render(bufnr)
lines, line_meta = views.category_view(tasks) lines, line_meta = views.category_view(tasks)
end end
if #lines == 0 and #_filter_predicates == 0 then
local default_cat = config.get().default_category
lines = { '# ' .. default_cat }
line_meta = { { type = 'header', category = default_cat } }
end
if #_filter_predicates > 0 then
table.insert(lines, 1, 'FILTER: ' .. table.concat(_filter_predicates, ' '))
table.insert(line_meta, 1, { type = 'filter' })
end
_meta = line_meta _meta = line_meta
_dirty_rows = {}
snapshot_folds(bufnr) snapshot_folds(bufnr)
vim.bo[bufnr].modifiable = true vim.bo[bufnr].modifiable = true
local saved = vim.bo[bufnr].undolevels local saved = vim.bo[bufnr].undolevels
vim.bo[bufnr].undolevels = -1 vim.bo[bufnr].undolevels = -1
_rendering = true
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
_rendering = false
vim.bo[bufnr].modified = false vim.bo[bufnr].modified = false
vim.bo[bufnr].undolevels = saved vim.bo[bufnr].undolevels = saved
setup_syntax(bufnr) setup_syntax(bufnr)
apply_extmarks(bufnr, line_meta) apply_extmarks(bufnr, line_meta)
local folding = config.resolve_folding()
for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do
if current_view == 'category' then if current_view == 'category' and folding.enabled then
vim.wo[winid].foldmethod = 'expr' vim.wo[winid].foldmethod = 'expr'
vim.wo[winid].foldexpr = 'v:lua.require("pending.buffer").get_fold()' vim.wo[winid].foldexpr = 'v:lua.require("pending.buffer").get_fold()'
vim.wo[winid].foldlevel = 99 vim.wo[winid].foldlevel = 99
vim.wo[winid].foldenable = true vim.wo[winid].foldenable = true
if folding.foldtext then
vim.wo[winid].foldtext = 'v:lua.require("pending.buffer").get_foldtext()'
else
vim.wo[winid].foldtext = 'foldtext()'
end
else else
vim.wo[winid].foldmethod = 'manual' vim.wo[winid].foldmethod = 'manual'
vim.wo[winid].foldenable = false vim.wo[winid].foldenable = false
@ -256,7 +644,9 @@ function M.render(bufnr)
restore_folds(bufnr) restore_folds(bufnr)
end end
---@return nil
function M.toggle_view() function M.toggle_view()
snapshot_folds(task_bufnr)
if current_view == 'category' then if current_view == 'category' then
current_view = 'priority' current_view = 'priority'
else else
@ -268,7 +658,9 @@ end
---@return integer bufnr ---@return integer bufnr
function M.open() function M.open()
setup_highlights() setup_highlights()
store.load() if _store then
_store:load()
end
if task_winid and vim.api.nvim_win_is_valid(task_winid) then if task_winid and vim.api.nvim_win_is_valid(task_winid) then
vim.api.nvim_set_current_win(task_winid) vim.api.nvim_set_current_win(task_winid)
@ -279,6 +671,7 @@ function M.open()
if not (task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr)) then if not (task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr)) then
task_bufnr = vim.api.nvim_create_buf(true, false) task_bufnr = vim.api.nvim_create_buf(true, false)
set_buf_options(task_bufnr) set_buf_options(task_bufnr)
M.attach_bytes(task_bufnr)
end end
vim.cmd('botright new') vim.cmd('botright new')

173
lua/pending/complete.lua Normal file
View file

@ -0,0 +1,173 @@
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 s = require('pending.buffer').store()
if not s then
return {}
end
local seen = {}
local result = {}
for _, task in ipairs(s: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 },
}
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
end
return matches
end
return M

View file

@ -1,6 +1,53 @@
---@class pending.FoldingConfig
---@field foldtext? string|false
---@class pending.ResolvedFolding
---@field enabled boolean
---@field foldtext string|false
---@class pending.Icons
---@field pending string
---@field done string
---@field priority string
---@field due string
---@field recur string
---@field category string
---@class pending.GcalConfig ---@class pending.GcalConfig
---@field calendar? string ---@field remote_delete? boolean
---@field credentials_path? string ---@field credentials_path? string
---@field client_id? string
---@field client_secret? string
---@class pending.GtasksConfig
---@field remote_delete? boolean
---@field credentials_path? string
---@field client_id? string
---@field client_secret? string
---@class pending.SyncConfig
---@field remote_delete? boolean
---@field gcal? pending.GcalConfig
---@field gtasks? pending.GtasksConfig
---@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 filter? 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
---@class pending.Config ---@class pending.Config
---@field data_path string ---@field data_path string
@ -8,9 +55,17 @@
---@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 input_date_formats? string[]
---@field category_order? string[] ---@field category_order? string[]
---@field drawer_height? integer ---@field drawer_height? integer
---@field gcal? pending.GcalConfig ---@field debug? boolean
---@field keymaps pending.Keymaps
---@field folding? boolean|pending.FoldingConfig
---@field sync? pending.SyncConfig
---@field eol_format? string
---@field icons pending.Icons
---@class pending.config ---@class pending.config
local M = {} local M = {}
@ -22,7 +77,39 @@ 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',
eol_format = '%c %r %d',
folding = true,
category_order = {}, category_order = {},
keymaps = {
close = 'q',
toggle = '<CR>',
view = '<Tab>',
priority = '!',
date = 'D',
undo = 'U',
filter = 'F',
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 = {},
icons = {
pending = ' ',
done = 'x',
priority = '!',
due = '.',
recur = '~',
category = '#',
},
} }
---@type pending.Config? ---@type pending.Config?
@ -38,8 +125,20 @@ function M.get()
return _resolved return _resolved
end end
---@return nil
function M.reset() function M.reset()
_resolved = nil _resolved = nil
end end
---@return pending.ResolvedFolding
function M.resolve_folding()
local raw = M.get().folding
if raw == false then
return { enabled = false, foldtext = false }
elseif raw == true or raw == nil then
return { enabled = true, foldtext = '%c (%n tasks)' }
end
return { enabled = true, foldtext = raw.foldtext or false }
end
return M return M

View file

@ -1,6 +1,5 @@
local config = require('pending.config') local config = require('pending.config')
local parse = require('pending.parse') local parse = require('pending.parse')
local store = require('pending.store')
---@class pending.ParsedEntry ---@class pending.ParsedEntry
---@field type 'task'|'header'|'blank' ---@field type 'task'|'header'|'blank'
@ -10,6 +9,8 @@ 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 lnum integer ---@field lnum integer
---@class pending.diff ---@class pending.diff
@ -25,16 +26,21 @@ end
function M.parse_buffer(lines) function M.parse_buffer(lines)
local result = {} local result = {}
local current_category = nil local current_category = nil
local start = 1
if lines[1] and lines[1]:match('^FILTER:') then
start = 2
end
for i, line in ipairs(lines) do for i = start, #lines do
local id, body = line:match('^/(%d+)/(- %[.%] .*)$') local line = lines[i]
local id, body = line:match('^/(%d+)/(- %[.?%] .*)$')
if not id then if not id then
body = line:match('^(- %[.%] .*)$') body = line:match('^(- %[.?%] .*)$')
end end
if line == '' then if line == '' then
table.insert(result, { type = 'blank', lnum = i }) table.insert(result, { type = 'blank', lnum = i })
elseif id or body then elseif id or body then
local stripped = body:match('^- %[.%] (.*)$') or body local stripped = body:match('^- %[.?%] (.*)$') or body
local state_char = body:match('^- %[(.-)%]') or ' ' local state_char = body:match('^- %[(.-)%]') or ' '
local priority = state_char == '!' and 1 or 0 local priority = state_char == '!' and 1 or 0
local status = state_char == 'x' and 'done' or 'pending' local status = state_char == 'x' and 'done' or 'pending'
@ -48,11 +54,13 @@ 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,
lnum = i, lnum = i,
}) })
end end
elseif line:match('^## (.+)$') then elseif line:match('^# (.+)$') then
current_category = line:match('^## (.+)$') current_category = line:match('^# (.+)$')
table.insert(result, { type = 'header', category = current_category, lnum = i }) table.insert(result, { type = 'header', category = current_category, lnum = i })
end end
end end
@ -61,10 +69,13 @@ function M.parse_buffer(lines)
end end
---@param lines string[] ---@param lines string[]
function M.apply(lines) ---@param s pending.Store
---@param hidden_ids? table<integer, true>
---@return nil
function M.apply(lines, s, 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 = s:data()
local old_by_id = {} local old_by_id = {}
for _, task in ipairs(data.tasks) do for _, task in ipairs(data.tasks) do
@ -85,11 +96,13 @@ function M.apply(lines)
if entry.id and old_by_id[entry.id] then if entry.id and old_by_id[entry.id] then
if seen_ids[entry.id] then if seen_ids[entry.id] then
store.add({ s:add({
description = entry.description, description = entry.description,
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
@ -108,10 +121,20 @@ function M.apply(lines)
task.priority = entry.priority task.priority = entry.priority
changed = true changed = true
end end
if task.due ~= entry.due then if entry.due ~= nil and task.due ~= entry.due then
task.due = entry.due task.due = entry.due
changed = true changed = true
end end
if entry.rec ~= nil then
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
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
@ -130,11 +153,13 @@ function M.apply(lines)
end end
end end
else else
store.add({ s:add({
description = entry.description, description = entry.description,
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
@ -143,14 +168,14 @@ function M.apply(lines)
end end
for id, task in pairs(old_by_id) do for id, task in pairs(old_by_id) do
if not seen_ids[id] then if not seen_ids[id] and not (hidden_ids and hidden_ids[id]) then
task.status = 'deleted' task.status = 'deleted'
task['end'] = now task['end'] = now
task.modified = now task.modified = now
end end
end end
store.save() s:save()
end end
return M return M

View file

@ -1,5 +1,6 @@
local M = {} local M = {}
---@return nil
function M.check() function M.check()
vim.health.start('pending.nvim') vim.health.start('pending.nvim')
@ -9,42 +10,54 @@ function M.check()
return return
end end
local cfg = config.get() config.get()
vim.health.ok('Config loaded') vim.health.ok('Config loaded')
vim.health.info('Data path: ' .. cfg.data_path)
local data_dir = vim.fn.fnamemodify(cfg.data_path, ':h') local store_ok, store = pcall(require, 'pending.store')
if vim.fn.isdirectory(data_dir) == 1 then if not store_ok then
vim.health.ok('Data directory exists: ' .. data_dir) vim.health.error('Failed to load pending.store')
else return
vim.health.warn('Data directory does not exist yet: ' .. data_dir)
end end
if vim.fn.filereadable(cfg.data_path) == 1 then local resolved_path = store.resolve_path()
local store_ok, store = pcall(require, 'pending.store') vim.health.info('Store path: ' .. resolved_path)
if store_ok then
local load_ok, err = pcall(store.load) if vim.fn.filereadable(resolved_path) == 1 then
if load_ok then local s = store.new(resolved_path)
local tasks = store.tasks() local load_ok, err = pcall(function()
vim.health.ok('Data file loaded: ' .. #tasks .. ' tasks') s:load()
else end)
vim.health.error('Failed to load data file: ' .. tostring(err)) if load_ok then
local tasks = s: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
vim.health.error('Failed to load data file: ' .. tostring(err))
end
end
local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
if #sync_paths == 0 then
vim.health.info('No sync backends found')
else
for _, path in ipairs(sync_paths) do
local name = vim.fn.fnamemodify(path, ':t:r')
local bok, backend = pcall(require, 'pending.sync.' .. name)
if bok and backend.name and type(backend.health) == 'function' then
vim.health.start('pending.nvim: sync/' .. name)
backend.health()
end end
end end
else
vim.health.info('No data file yet (will be created on first save)')
end
if vim.fn.executable('curl') == 1 then
vim.health.ok('curl found (required for Google Calendar sync)')
else
vim.health.warn('curl not found (needed for Google Calendar sync)')
end
if vim.fn.executable('openssl') == 1 then
vim.health.ok('openssl found (required for OAuth PKCE)')
else
vim.health.warn('openssl not found (needed for Google Calendar OAuth)')
end end
end end

File diff suppressed because it is too large Load diff

30
lua/pending/log.lua Normal file
View file

@ -0,0 +1,30 @@
---@class pending.log
local M = {}
local PREFIX = '[pending.nvim]: '
---@param msg string
function M.info(msg)
vim.notify(PREFIX .. msg)
end
---@param msg string
function M.warn(msg)
vim.notify(PREFIX .. msg, vim.log.levels.WARN)
end
---@param msg string
function M.error(msg)
vim.notify(PREFIX .. msg, vim.log.levels.ERROR)
end
---@param msg string
---@param override? boolean
function M.debug(msg, override)
local cfg = require('pending.config').get()
if cfg.debug or override then
vim.notify(PREFIX .. msg, vim.log.levels.DEBUG)
end
end
return M

View file

@ -24,11 +24,92 @@ 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,
@ -39,53 +120,402 @@ 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 name string
---@return integer?
local function month_name_to_num(name)
return month_map[name:lower():sub(1, 3)]
end
---@param fmt string
---@return string, string[]
local function input_format_to_pattern(fmt)
local fields = {}
local parts = {}
local i = 1
while i <= #fmt do
local c = fmt:sub(i, i)
if c == '%' and i < #fmt then
local spec = fmt:sub(i + 1, i + 1)
if spec == '%' then
parts[#parts + 1] = '%%'
i = i + 2
elseif spec == 'Y' then
fields[#fields + 1] = 'year'
parts[#parts + 1] = '(%d%d%d%d)'
i = i + 2
elseif spec == 'y' then
fields[#fields + 1] = 'year2'
parts[#parts + 1] = '(%d%d)'
i = i + 2
elseif spec == 'm' then
fields[#fields + 1] = 'month_num'
parts[#parts + 1] = '(%d%d?)'
i = i + 2
elseif spec == 'd' or spec == 'e' then
fields[#fields + 1] = 'day'
parts[#parts + 1] = '(%d%d?)'
i = i + 2
elseif spec == 'b' or spec == 'B' then
fields[#fields + 1] = 'month_name'
parts[#parts + 1] = '(%a+)'
i = i + 2
else
parts[#parts + 1] = vim.pesc(c)
i = i + 1
end
else
parts[#parts + 1] = vim.pesc(c)
i = i + 1
end
end
return '^' .. table.concat(parts) .. '$', fields
end
---@param date_input string
---@param time_suffix? string
---@return string?
local function try_input_date_formats(date_input, time_suffix)
local fmts = config.get().input_date_formats
if not fmts or #fmts == 0 then
return nil
end
local today = os.date('*t') --[[@as osdate]]
for _, fmt in ipairs(fmts) do
local pat, fields = input_format_to_pattern(fmt)
local caps = { date_input:match(pat) }
if caps[1] ~= nil then
local year, month, day
for j = 1, #fields do
local field = fields[j]
local val = caps[j]
if field == 'year' then
year = tonumber(val)
elseif field == 'year2' then
local y = tonumber(val) --[[@as integer]]
year = y + (y >= 70 and 1900 or 2000)
elseif field == 'month_num' then
month = tonumber(val)
elseif field == 'day' then
day = tonumber(val)
elseif field == 'month_name' then
month = month_name_to_num(val)
end
end
if month and day then
if not year then
year = today.year
if month < today.month or (month == today.month and day < today.day) then
year = year + 1
end
end
local t = os.time({ year = year, month = month, day = day })
local check = os.date('*t', t) --[[@as osdate]]
if check.year == year and check.month == month and check.day == day then
return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
end
end
end
end
return nil
end
---@param text string ---@param text string
---@return string|nil ---@return string|nil
function M.resolve_date(text) function M.resolve_date(text)
local lower = text:lower() local date_input, time_suffix = text:match('^(.+)@(.+)$')
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' then if lower == 'today' or lower == 'eod' then
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]] return append_time(today_str(today), time_suffix)
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 os.date( return append_time(
'%Y-%m-%d', os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) --[[@as string]],
os.time({ year = today.year, month = today.month, day = today.day + 1 }) time_suffix
) --[[@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 os.date( return append_time(
'%Y-%m-%d', os.date(
os.time({ '%Y-%m-%d',
year = today.year, os.time({
month = today.month, year = today.year,
day = today.day + ( month = today.month,
tonumber(n) --[[@as integer]] day = today.day + (
), 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 os.date( return append_time(
'%Y-%m-%d', os.date(
os.time({ year = today.year, month = today.month, day = today.day + delta }) '%Y-%m-%d',
) --[[@as string]] os.time({ year = today.year, month = today.month, day = today.day + delta })
) --[[@as string]],
time_suffix
)
end end
return nil return try_input_date_formats(date_input, time_suffix)
end end
---@param text string ---@param text string
---@return string description ---@return string description
---@return { due?: string, cat?: string } metadata ---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } 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
@ -95,8 +525,10 @@ function M.body(text)
local metadata = {} local metadata = {}
local i = #tokens local i = #tokens
local dk = date_key() local dk = date_key()
local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$' local rk = recur_key()
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]
@ -105,7 +537,7 @@ function M.body(text)
if metadata.due then if metadata.due then
break break
end end
if not is_valid_date(due_val) then if not is_valid_datetime(due_val) then
break break
end end
metadata.due = due_val metadata.due = due_val
@ -131,7 +563,25 @@ function M.body(text)
metadata.cat = cat_val metadata.cat = cat_val
i = i - 1 i = i - 1
else else
break local rec_val = token:match(rec_pattern)
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
break
end
end end
end end
end end
@ -148,7 +598,7 @@ end
---@param text string ---@param text string
---@return string description ---@return string description
---@return { due?: string, cat?: string } metadata ---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } 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
@ -165,4 +615,39 @@ 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

188
lua/pending/recur.lua Normal file
View file

@ -0,0 +1,188 @@
---@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,6 +7,8 @@ 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
@ -17,21 +19,28 @@ 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[][]
---@field folded_categories string[]
---@class pending.Store
---@field path string
---@field _data pending.Data?
local Store = {}
Store.__index = Store
---@class pending.store ---@class pending.store
local M = {} local M = {}
local SUPPORTED_VERSION = 1 local SUPPORTED_VERSION = 1
---@type pending.Data?
local _data = nil
---@return pending.Data ---@return pending.Data
local function empty_data() local function empty_data()
return { return {
version = SUPPORTED_VERSION, version = SUPPORTED_VERSION,
next_id = 1, next_id = 1,
tasks = {}, tasks = {},
undo = {},
folded_categories = {},
} }
end end
@ -56,6 +65,8 @@ 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,
@ -81,6 +92,12 @@ 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
@ -105,6 +122,8 @@ 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'],
@ -123,18 +142,18 @@ local function table_to_task(t)
end end
---@return pending.Data ---@return pending.Data
function M.load() function Store:load()
local path = config.get().data_path local path = self.path
local f = io.open(path, 'r') local f = io.open(path, 'r')
if not f then if not f then
_data = empty_data() self._data = empty_data()
return _data return self._data
end end
local content = f:read('*a') local content = f:read('*a')
f:close() f:close()
if content == '' then if content == '' then
_data = empty_data() self._data = empty_data()
return _data return self._data
end end
local ok, decoded = pcall(vim.json.decode, content) local ok, decoded = pcall(vim.json.decode, content)
if not ok then if not ok then
@ -149,31 +168,52 @@ function M.load()
.. '. Please update the plugin.' .. '. Please update the plugin.'
) )
end end
_data = { self._data = {
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 = {},
folded_categories = decoded.folded_categories or {},
} }
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(self._data.tasks, table_to_task(t))
end end
return _data 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(self._data.undo, tasks)
end
end
return self._data
end end
function M.save() ---@return nil
if not _data then function Store:save()
if not self._data then
return return
end end
local path = config.get().data_path local path = self.path
ensure_dir(path) ensure_dir(path)
local out = { local out = {
version = _data.version, version = self._data.version,
next_id = _data.next_id, next_id = self._data.next_id,
tasks = {}, tasks = {},
undo = {},
folded_categories = self._data.folded_categories,
} }
for _, task in ipairs(_data.tasks) do for _, task in ipairs(self._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(self._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')
@ -190,22 +230,22 @@ function M.save()
end end
---@return pending.Data ---@return pending.Data
function M.data() function Store:data()
if not _data then if not self._data then
M.load() self:load()
end end
return _data --[[@as pending.Data]] return self._data --[[@as pending.Data]]
end end
---@return pending.Task[] ---@return pending.Task[]
function M.tasks() function Store:tasks()
return M.data().tasks return self:data().tasks
end end
---@return pending.Task[] ---@return pending.Task[]
function M.active_tasks() function Store:active_tasks()
local result = {} local result = {}
for _, task in ipairs(M.tasks()) do for _, task in ipairs(self:tasks()) do
if task.status ~= 'deleted' then if task.status ~= 'deleted' then
table.insert(result, task) table.insert(result, task)
end end
@ -215,8 +255,8 @@ end
---@param id integer ---@param id integer
---@return pending.Task? ---@return pending.Task?
function M.get(id) function Store:get(id)
for _, task in ipairs(M.tasks()) do for _, task in ipairs(self:tasks()) do
if task.id == id then if task.id == id then
return task return task
end end
@ -224,10 +264,10 @@ function M.get(id)
return nil return nil
end end
---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, order?: integer, _extra?: table } ---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, recur?: string, recur_mode?: string, order?: integer, _extra?: table }
---@return pending.Task ---@return pending.Task
function M.add(fields) function Store:add(fields)
local data = M.data() local data = self:data()
local now = timestamp() local now = timestamp()
local task = { local task = {
id = data.next_id, id = data.next_id,
@ -236,6 +276,8 @@ 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,
@ -250,15 +292,19 @@ end
---@param id integer ---@param id integer
---@param fields table<string, any> ---@param fields table<string, any>
---@return pending.Task? ---@return pending.Task?
function M.update(id, fields) function Store:update(id, fields)
local task = M.get(id) local task = self:get(id)
if not task then if not task then
return nil return nil
end end
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
task[k] = v if v == vim.NIL then
task[k] = nil
else
task[k] = v
end
end end
end end
task.modified = now task.modified = now
@ -270,14 +316,14 @@ end
---@param id integer ---@param id integer
---@return pending.Task? ---@return pending.Task?
function M.delete(id) function Store:delete(id)
return M.update(id, { status = 'deleted', ['end'] = timestamp() }) return self:update(id, { status = 'deleted', ['end'] = timestamp() })
end end
---@param id integer ---@param id integer
---@return integer? ---@return integer?
function M.find_index(id) function Store:find_index(id)
for i, task in ipairs(M.tasks()) do for i, task in ipairs(self:tasks()) do
if task.id == id then if task.id == id then
return i return i
end end
@ -286,14 +332,15 @@ function M.find_index(id)
end end
---@param tasks pending.Task[] ---@param tasks pending.Task[]
function M.replace_tasks(tasks) ---@return nil
M.data().tasks = tasks function Store:replace_tasks(tasks)
self:data().tasks = tasks
end end
---@return pending.Task[] ---@return pending.Task[]
function M.snapshot() function Store:snapshot()
local result = {} local result = {}
for _, task in ipairs(M.active_tasks()) do for _, task in ipairs(self:active_tasks()) do
local copy = {} local copy = {}
for k, v in pairs(task) do for k, v in pairs(task) do
if k ~= '_extra' then if k ~= '_extra' then
@ -311,13 +358,48 @@ function M.snapshot()
return result return result
end end
---@param id integer ---@return pending.Task[][]
function M.set_next_id(id) function Store:undo_stack()
M.data().next_id = id return self:data().undo
end end
function M.unload() ---@param stack pending.Task[][]
_data = nil ---@return nil
function Store:set_undo_stack(stack)
self:data().undo = stack
end
---@param id integer
---@return nil
function Store:set_next_id(id)
self:data().next_id = id
end
---@return string[]
function Store:get_folded_categories()
return self:data().folded_categories
end
---@param cats string[]
---@return nil
function Store:set_folded_categories(cats)
self:data().folded_categories = cats
end
---@return nil
function Store:unload()
self._data = nil
end
---@param path string
---@return pending.Store
function M.new(path)
return setmetatable({ path = path, _data = nil }, Store)
end
---@return string
function M.resolve_path()
return config.get().data_path
end end
return M return M

View file

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

547
lua/pending/sync/gtasks.lua Normal file
View file

@ -0,0 +1,547 @@
local config = require('pending.config')
local log = require('pending.log')
local oauth = require('pending.sync.oauth')
local M = {}
M.name = 'gtasks'
local BASE_URL = 'https://tasks.googleapis.com/tasks/v1'
---@param access_token string
---@return table<string, string>? name_to_id
---@return string? err
local function get_all_tasklists(access_token)
local data, err =
oauth.curl_request('GET', BASE_URL .. '/users/@me/lists', oauth.auth_headers(access_token))
if err then
return nil, err
end
local result = {}
for _, item in ipairs(data and data.items or {}) do
result[item.title] = item.id
end
return result, nil
end
---@param access_token string
---@param name string
---@param existing table<string, string>
---@return string? list_id
---@return string? err
local function find_or_create_tasklist(access_token, name, existing)
if existing[name] then
return existing[name], nil
end
local body = vim.json.encode({ title = name })
local created, err = oauth.curl_request(
'POST',
BASE_URL .. '/users/@me/lists',
oauth.auth_headers(access_token),
body
)
if err then
return nil, err
end
local id = created and created.id
if id then
existing[name] = id
end
return id, nil
end
---@param access_token string
---@param list_id string
---@return table[]? items
---@return string? err
local function list_gtasks(access_token, list_id)
local url = BASE_URL
.. '/lists/'
.. oauth.url_encode(list_id)
.. '/tasks?showCompleted=true&showHidden=true'
local data, err = oauth.curl_request('GET', url, oauth.auth_headers(access_token))
if err then
return nil, err
end
return data and data.items or {}, nil
end
---@param access_token string
---@param list_id string
---@param body table
---@return string? task_id
---@return string? err
local function create_gtask(access_token, list_id, body)
local data, err = oauth.curl_request(
'POST',
BASE_URL .. '/lists/' .. oauth.url_encode(list_id) .. '/tasks',
oauth.auth_headers(access_token),
vim.json.encode(body)
)
if err then
return nil, err
end
return data and data.id, nil
end
---@param access_token string
---@param list_id string
---@param task_id string
---@param body table
---@return string? err
local function update_gtask(access_token, list_id, task_id, body)
local _, err = oauth.curl_request(
'PATCH',
BASE_URL .. '/lists/' .. oauth.url_encode(list_id) .. '/tasks/' .. oauth.url_encode(task_id),
oauth.auth_headers(access_token),
vim.json.encode(body)
)
return err
end
---@param access_token string
---@param list_id string
---@param task_id string
---@return string? err
local function delete_gtask(access_token, list_id, task_id)
local _, err = oauth.curl_request(
'DELETE',
BASE_URL .. '/lists/' .. oauth.url_encode(list_id) .. '/tasks/' .. oauth.url_encode(task_id),
oauth.auth_headers(access_token)
)
return err
end
---@param due string YYYY-MM-DD or YYYY-MM-DDThh:mm
---@return string RFC 3339
local function due_to_rfc3339(due)
local date = due:match('^(%d%d%d%d%-%d%d%-%d%d)')
return (date or due) .. 'T00:00:00.000Z'
end
---@param rfc string RFC 3339 from GTasks
---@return string YYYY-MM-DD
local function rfc3339_to_date(rfc)
return rfc:match('^(%d%d%d%d%-%d%d%-%d%d)') or rfc
end
---@param task pending.Task
---@return string?
local function build_notes(task)
local parts = {}
if task.priority and task.priority > 0 then
table.insert(parts, 'pri:' .. task.priority)
end
if task.recur then
local spec = task.recur
if task.recur_mode == 'completion' then
spec = '!' .. spec
end
table.insert(parts, 'rec:' .. spec)
end
if #parts == 0 then
return nil
end
return table.concat(parts, ' ')
end
---@param notes string?
---@return integer priority
---@return string? recur
---@return string? recur_mode
local function parse_notes(notes)
if not notes then
return 0, nil, nil
end
local priority = 0
local recur = nil
local recur_mode = nil
local pri = notes:match('pri:(%d+)')
if pri then
priority = tonumber(pri) or 0
end
local rec = notes:match('rec:(!?[%w]+)')
if rec then
if rec:sub(1, 1) == '!' then
recur = rec:sub(2)
recur_mode = 'completion'
else
recur = rec
end
end
return priority, recur, recur_mode
end
---@return boolean
local function allow_remote_delete()
local cfg = config.get()
local sync = cfg.sync or {}
local per = (sync.gtasks or {}) --[[@as pending.GtasksConfig]]
if per.remote_delete ~= nil then
return per.remote_delete == true
end
return sync.remote_delete == true
end
---@param task pending.Task
---@param now_ts string
local function unlink_remote(task, now_ts)
task._extra['_gtasks_task_id'] = nil
task._extra['_gtasks_list_id'] = nil
task._extra['_gtasks_synced_at'] = nil
if next(task._extra) == nil then
task._extra = nil
end
task.modified = now_ts
end
---@param parts {[1]: integer, [2]: string}[]
---@return string
local function fmt_counts(parts)
local items = {}
for _, p in ipairs(parts) do
if p[1] > 0 then
table.insert(items, p[1] .. ' ' .. p[2])
end
end
if #items == 0 then
return 'nothing to do'
end
return table.concat(items, ' | ')
end
---@param task pending.Task
---@return table
local function task_to_gtask(task)
local body = {
title = task.description,
status = task.status == 'done' and 'completed' or 'needsAction',
}
if task.due then
body.due = due_to_rfc3339(task.due)
end
local notes = build_notes(task)
if notes then
body.notes = notes
end
return body
end
---@param gtask table
---@param category string
---@return table fields for store:add / store:update
local function gtask_to_fields(gtask, category)
local priority, recur, recur_mode = parse_notes(gtask.notes)
local fields = {
description = gtask.title or '',
category = category,
status = gtask.status == 'completed' and 'done' or 'pending',
priority = priority,
recur = recur,
recur_mode = recur_mode,
}
if gtask.due then
fields.due = rfc3339_to_date(gtask.due)
end
return fields
end
---@param s pending.Store
---@return table<string, pending.Task>
local function build_id_index(s)
---@type table<string, pending.Task>
local index = {}
for _, task in ipairs(s:tasks()) do
local extra = task._extra or {}
local gtid = extra['_gtasks_task_id'] --[[@as string?]]
if gtid then
index[gtid] = task
end
end
return index
end
---@param access_token string
---@param tasklists table<string, string>
---@param s pending.Store
---@param now_ts string
---@param by_gtasks_id table<string, pending.Task>
---@return integer created
---@return integer updated
---@return integer deleted
---@return integer failed
local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local created, updated, deleted, failed = 0, 0, 0, 0
for _, task in ipairs(s:tasks()) do
local extra = task._extra or {}
local gtid = extra['_gtasks_task_id'] --[[@as string?]]
local list_id = extra['_gtasks_list_id'] --[[@as string?]]
if task.status == 'deleted' and gtid and list_id then
if allow_remote_delete() then
local err = delete_gtask(access_token, list_id, gtid)
if err then
log.warn('Failed to delete remote task: ' .. err)
failed = failed + 1
else
unlink_remote(task, now_ts)
deleted = deleted + 1
end
else
log.debug(
'gtasks: remote delete skipped (remote_delete disabled) — unlinked task #' .. task.id
)
unlink_remote(task, now_ts)
deleted = deleted + 1
end
elseif task.status ~= 'deleted' then
if gtid and list_id then
local synced_at = extra['_gtasks_synced_at'] --[[@as string?]]
if not synced_at or task.modified > synced_at then
local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task))
if err then
log.warn('Failed to update remote task: ' .. err)
failed = failed + 1
else
task._extra = task._extra or {}
task._extra['_gtasks_synced_at'] = now_ts
updated = updated + 1
end
end
elseif task.status == 'pending' then
local cat = task.category or config.get().default_category
local lid, err = find_or_create_tasklist(access_token, cat, tasklists)
if not err and lid then
local new_id, create_err = create_gtask(access_token, lid, task_to_gtask(task))
if create_err then
log.warn('Failed to create remote task: ' .. create_err)
failed = failed + 1
elseif new_id then
if not task._extra then
task._extra = {}
end
task._extra['_gtasks_task_id'] = new_id
task._extra['_gtasks_list_id'] = lid
task._extra['_gtasks_synced_at'] = now_ts
task.modified = now_ts
by_gtasks_id[new_id] = task
created = created + 1
end
end
end
end
end
return created, updated, deleted, failed
end
---@param access_token string
---@param tasklists table<string, string>
---@param s pending.Store
---@param now_ts string
---@param by_gtasks_id table<string, pending.Task>
---@return integer created
---@return integer updated
---@return integer failed
---@return table<string, true> seen_remote_ids
---@return table<string, true> fetched_list_ids
local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local created, updated, failed = 0, 0, 0
---@type table<string, true>
local seen_remote_ids = {}
---@type table<string, true>
local fetched_list_ids = {}
for list_name, list_id in pairs(tasklists) do
local items, err = list_gtasks(access_token, list_id)
if err then
log.warn('Failed to fetch task list "' .. list_name .. '": ' .. err)
failed = failed + 1
else
fetched_list_ids[list_id] = true
for _, gtask in ipairs(items or {}) do
seen_remote_ids[gtask.id] = true
local local_task = by_gtasks_id[gtask.id]
if local_task then
local gtask_updated = gtask.updated or ''
local local_modified = local_task.modified or ''
if gtask_updated > local_modified then
local fields = gtask_to_fields(gtask, list_name)
for k, v in pairs(fields) do
local_task[k] = v
end
local_task._extra = local_task._extra or {}
local_task._extra['_gtasks_synced_at'] = now_ts
local_task.modified = now_ts
updated = updated + 1
end
else
local fields = gtask_to_fields(gtask, list_name)
fields._extra = {
_gtasks_task_id = gtask.id,
_gtasks_list_id = list_id,
_gtasks_synced_at = now_ts,
}
local new_task = s:add(fields)
by_gtasks_id[gtask.id] = new_task
created = created + 1
end
end
end
end
return created, updated, failed, seen_remote_ids, fetched_list_ids
end
---@param s pending.Store
---@param seen_remote_ids table<string, true>
---@param fetched_list_ids table<string, true>
---@param now_ts string
---@return integer unlinked
local function detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts)
local unlinked = 0
for _, task in ipairs(s:tasks()) do
local extra = task._extra or {}
local gtid = extra['_gtasks_task_id']
local list_id = extra['_gtasks_list_id']
if
task.status ~= 'deleted'
and gtid
and list_id
and fetched_list_ids[list_id]
and not seen_remote_ids[gtid]
then
task._extra['_gtasks_task_id'] = nil
task._extra['_gtasks_list_id'] = nil
task._extra['_gtasks_synced_at'] = nil
if next(task._extra) == nil then
task._extra = nil
end
task.modified = now_ts
unlinked = unlinked + 1
end
end
return unlinked
end
---@param access_token string
---@return table<string, string>? tasklists
---@return pending.Store? s
---@return string? now_ts
local function sync_setup(access_token)
local tasklists, tl_err = get_all_tasklists(access_token)
if tl_err or not tasklists then
log.error(tl_err or 'Failed to fetch task lists.')
return nil, nil, nil
end
local s = require('pending').store()
local now_ts = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
return tasklists, s, now_ts
end
function M.push()
oauth.with_token(oauth.google_client, 'gtasks', function(access_token)
local tasklists, s, now_ts = sync_setup(access_token)
if not tasklists then
return
end
---@cast s pending.Store
---@cast now_ts string
local by_gtasks_id = build_id_index(s)
local created, updated, deleted, failed =
push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
s:save()
require('pending')._recompute_counts()
local buffer = require('pending.buffer')
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then
buffer.render(buffer.bufnr())
end
log.info('gtasks push: ' .. fmt_counts({
{ created, 'added' },
{ updated, 'updated' },
{ deleted, 'deleted' },
{ failed, 'failed' },
}))
end)
end
function M.pull()
oauth.with_token(oauth.google_client, 'gtasks', function(access_token)
local tasklists, s, now_ts = sync_setup(access_token)
if not tasklists then
return
end
---@cast s pending.Store
---@cast now_ts string
local by_gtasks_id = build_id_index(s)
local created, updated, failed, seen_remote_ids, fetched_list_ids =
pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local unlinked = detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts)
s:save()
require('pending')._recompute_counts()
local buffer = require('pending.buffer')
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then
buffer.render(buffer.bufnr())
end
log.info('gtasks pull: ' .. fmt_counts({
{ created, 'added' },
{ updated, 'updated' },
{ unlinked, 'unlinked' },
{ failed, 'failed' },
}))
end)
end
function M.sync()
oauth.with_token(oauth.google_client, 'gtasks', function(access_token)
local tasklists, s, now_ts = sync_setup(access_token)
if not tasklists then
return
end
---@cast s pending.Store
---@cast now_ts string
local by_gtasks_id = build_id_index(s)
local pushed_create, pushed_update, pushed_delete, pushed_failed =
push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local pulled_create, pulled_update, pulled_failed, seen_remote_ids, fetched_list_ids =
pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local unlinked = detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts)
s:save()
require('pending')._recompute_counts()
local buffer = require('pending.buffer')
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then
buffer.render(buffer.bufnr())
end
log.info('gtasks sync — push: ' .. fmt_counts({
{ pushed_create, 'added' },
{ pushed_update, 'updated' },
{ pushed_delete, 'deleted' },
{ pushed_failed, 'failed' },
}) .. ' pull: ' .. fmt_counts({
{ pulled_create, 'added' },
{ pulled_update, 'updated' },
{ unlinked, 'unlinked' },
{ pulled_failed, 'failed' },
}))
end)
end
M._due_to_rfc3339 = due_to_rfc3339
M._rfc3339_to_date = rfc3339_to_date
M._build_notes = build_notes
M._parse_notes = parse_notes
M._task_to_gtask = task_to_gtask
M._gtask_to_fields = gtask_to_fields
M._push_pass = push_pass
M._pull_pass = pull_pass
M._detect_remote_deletions = detect_remote_deletions
---@return nil
function M.health()
oauth.health(M.name)
local tokens = oauth.google_client:load_tokens()
if tokens and tokens.refresh_token then
vim.health.ok('gtasks tokens found')
else
vim.health.info('no gtasks tokens — run :Pending auth')
end
end
return M

572
lua/pending/sync/oauth.lua Normal file
View file

@ -0,0 +1,572 @@
local config = require('pending.config')
local log = require('pending.log')
local TOKEN_URL = 'https://oauth2.googleapis.com/token'
local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
local BUNDLED_CLIENT_ID = 'PLACEHOLDER'
local BUNDLED_CLIENT_SECRET = 'PLACEHOLDER'
---@class pending.OAuthCredentials
---@field client_id string
---@field client_secret string
---@class pending.OAuthTokens
---@field access_token string
---@field refresh_token string
---@field expires_in? integer
---@field obtained_at? integer
---@class pending.OAuthClient
---@field name string
---@field scope string
---@field port integer
---@field config_key string
local OAuthClient = {}
OAuthClient.__index = OAuthClient
local _active_close = nil
local _sync_in_flight = false
---@class pending.oauth
local M = {}
---@param args string[]
---@param opts? table
---@return { code: integer, stdout: string, stderr: string }
function M.system(args, opts)
local co = coroutine.running()
if not co then
return vim.system(args, opts or {}):wait() --[[@as { code: integer, stdout: string, stderr: string }]]
end
vim.system(args, opts or {}, function(result)
vim.schedule(function()
coroutine.resume(co, result)
end)
end)
return coroutine.yield() --[[@as { code: integer, stdout: string, stderr: string }]]
end
---@param fn fun(): nil
function M.async(fn)
coroutine.resume(coroutine.create(fn))
end
---@param client pending.OAuthClient
---@param name string
---@param callback fun(access_token: string): nil
function M.with_token(client, name, callback)
if _sync_in_flight then
require('pending.log').warn(name .. ': Sync already in progress — please wait.')
return
end
_sync_in_flight = true
M.async(function()
local token = client:get_access_token()
if not token then
_sync_in_flight = false
require('pending.log').warn(name .. ': Not authenticated — run :Pending auth.')
return
end
local ok, err = pcall(callback, token)
_sync_in_flight = false
if not ok then
require('pending.log').error(name .. ': ' .. tostring(err))
end
end)
end
---@param str string
---@return string
function M.url_encode(str)
return (
str:gsub('([^%w%-%.%_%~])', function(c)
return string.format('%%%02X', string.byte(c))
end)
)
end
---@param path string
---@return table?
function M.load_json_file(path)
local f = io.open(path, 'r')
if not f then
return nil
end
local content = f:read('*a')
f:close()
if content == '' then
return nil
end
local ok, decoded = pcall(vim.json.decode, content)
if not ok then
return nil
end
return decoded
end
---@param path string
---@param data table
---@return boolean
function M.save_json_file(path, data)
local dir = vim.fn.fnamemodify(path, ':h')
if vim.fn.isdirectory(dir) == 0 then
vim.fn.mkdir(dir, 'p')
end
local f = io.open(path, 'w')
if not f then
return false
end
f:write(vim.json.encode(data))
f:close()
vim.fn.setfperm(path, 'rw-------')
return true
end
---@param method string
---@param url string
---@param headers? string[]
---@param body? string
---@return table? result
---@return string? err
function M.curl_request(method, url, headers, body)
local args = { 'curl', '-s', '-X', method }
for _, h in ipairs(headers or {}) do
table.insert(args, '-H')
table.insert(args, h)
end
if body then
table.insert(args, '-d')
table.insert(args, body)
end
table.insert(args, url)
local result = M.system(args, { text = true })
if result.code ~= 0 then
return nil, 'curl failed: ' .. (result.stderr or '')
end
if not result.stdout or result.stdout == '' then
return {}, nil
end
local ok, decoded = pcall(vim.json.decode, result.stdout)
if not ok then
return nil, 'failed to parse response: ' .. result.stdout
end
if decoded.error then
return nil, 'API error: ' .. (decoded.error.message or vim.json.encode(decoded.error))
end
return decoded, nil
end
---@param access_token string
---@return string[]
function M.auth_headers(access_token)
return {
'Authorization: Bearer ' .. access_token,
'Content-Type: application/json',
}
end
---@param backend_name string
---@return nil
function M.health(backend_name)
if vim.fn.executable('curl') == 1 then
vim.health.ok('curl found (required for ' .. backend_name .. ' sync)')
else
vim.health.warn('curl not found (needed for ' .. backend_name .. ' sync)')
end
end
---@return string
function OAuthClient:token_path()
return vim.fn.stdpath('data') .. '/pending/' .. self.name .. '_tokens.json'
end
---@return pending.OAuthCredentials
function OAuthClient:resolve_credentials()
local cfg = config.get()
local backend_cfg = (cfg.sync and cfg.sync[self.config_key]) or {}
if backend_cfg.client_id and backend_cfg.client_secret then
return {
client_id = backend_cfg.client_id,
client_secret = backend_cfg.client_secret,
}
end
local data_dir = vim.fn.stdpath('data') .. '/pending/'
local cred_paths = {}
if backend_cfg.credentials_path then
table.insert(cred_paths, backend_cfg.credentials_path)
end
table.insert(cred_paths, data_dir .. self.name .. '_credentials.json')
table.insert(cred_paths, data_dir .. 'google_credentials.json')
for _, cred_path in ipairs(cred_paths) do
if cred_path then
local creds = M.load_json_file(cred_path)
if creds then
if creds.installed then
creds = creds.installed
end
if creds.client_id and creds.client_secret then
return creds --[[@as pending.OAuthCredentials]]
end
end
end
end
return {
client_id = BUNDLED_CLIENT_ID,
client_secret = BUNDLED_CLIENT_SECRET,
}
end
---@return pending.OAuthTokens?
function OAuthClient:load_tokens()
return M.load_json_file(self:token_path()) --[[@as pending.OAuthTokens?]]
end
---@param tokens pending.OAuthTokens
---@return boolean
function OAuthClient:save_tokens(tokens)
return M.save_json_file(self:token_path(), tokens)
end
---@param creds pending.OAuthCredentials
---@param tokens pending.OAuthTokens
---@return pending.OAuthTokens?
function OAuthClient:refresh_access_token(creds, tokens)
local body = 'client_id='
.. M.url_encode(creds.client_id)
.. '&client_secret='
.. M.url_encode(creds.client_secret)
.. '&grant_type=refresh_token'
.. '&refresh_token='
.. M.url_encode(tokens.refresh_token)
local result = M.system({
'curl',
'-s',
'-X',
'POST',
'-H',
'Content-Type: application/x-www-form-urlencoded',
'-d',
body,
TOKEN_URL,
}, { text = true })
if result.code ~= 0 then
return nil
end
local ok, decoded = pcall(vim.json.decode, result.stdout or '')
if not ok or not decoded.access_token then
return nil
end
tokens.access_token = decoded.access_token --[[@as string]]
tokens.expires_in = decoded.expires_in --[[@as integer?]]
tokens.obtained_at = os.time()
self:save_tokens(tokens)
return tokens
end
---@return string?
function OAuthClient:get_access_token()
local creds = self:resolve_credentials()
local tokens = self:load_tokens()
if not tokens or not tokens.refresh_token then
return nil
end
local now = os.time()
local obtained = tokens.obtained_at or 0
local expires = tokens.expires_in or 3600
if now - obtained > expires - 60 then
tokens = self:refresh_access_token(creds, tokens)
if not tokens then
log.error(self.name .. ': Token refresh failed — re-authenticating...')
return nil
end
end
return tokens.access_token
end
---@return nil
function OAuthClient:setup()
local choice = vim.fn.inputlist({
self.name .. ' setup:',
'1. Enter client ID and secret',
'2. Load from JSON file path',
})
vim.cmd.redraw()
local id, secret
if choice == 1 then
while true do
id = vim.trim(vim.fn.input(self.name .. ' client ID: '))
if id == '' then
return
end
if id:match('^%d+%-[%w_]+%.apps%.googleusercontent%.com$') then
break
end
vim.cmd.redraw()
vim.api.nvim_echo({
{
'invalid client ID — expected <numbers>-<hash>.apps.googleusercontent.com',
'ErrorMsg',
},
}, false, {})
end
while true do
secret = vim.trim(vim.fn.inputsecret(self.name .. ' client secret: '))
if secret == '' then
return
end
if secret:match('^GOCSPX%-') then
break
end
vim.cmd.redraw()
vim.api.nvim_echo(
{ { 'invalid client secret — expected GOCSPX-...', 'ErrorMsg' } },
false,
{}
)
end
elseif choice == 2 then
local fpath
while true do
fpath = vim.trim(vim.fn.input(self.name .. ' credentials file: ', '', 'file'))
if fpath == '' then
return
end
fpath = vim.fn.expand(fpath)
local creds = M.load_json_file(fpath)
if creds then
if creds.installed then
creds = creds.installed
end
if creds.client_id and creds.client_secret then
id = creds.client_id
secret = creds.client_secret
break
end
end
vim.cmd.redraw()
vim.api.nvim_echo(
{ { 'could not read client_id/client_secret from ' .. fpath, 'ErrorMsg' } },
false,
{}
)
end
else
return
end
vim.schedule(function()
local path = vim.fn.stdpath('data') .. '/pending/google_credentials.json'
local ok = M.save_json_file(path, { client_id = id, client_secret = secret })
if not ok then
log.error(self.name .. ': Failed to save credentials.')
return
end
log.info(self.name .. ': Credentials saved, starting authorization...')
self:auth()
end)
end
---@param on_complete? fun(): nil
---@return nil
function OAuthClient:auth(on_complete)
if _active_close then
_active_close()
_active_close = nil
end
local creds = self:resolve_credentials()
if creds.client_id == BUNDLED_CLIENT_ID then
log.error(self.name .. ': No credentials configured — run :Pending auth.')
return
end
local port = self.port
local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
local verifier = {}
math.randomseed(vim.uv.hrtime())
for _ = 1, 64 do
local idx = math.random(1, #verifier_chars)
table.insert(verifier, verifier_chars:sub(idx, idx))
end
local code_verifier = table.concat(verifier)
local hex = vim.fn.sha256(code_verifier)
local binary = hex:gsub('..', function(h)
return string.char(tonumber(h, 16))
end)
local code_challenge = vim.base64.encode(binary):gsub('+', '-'):gsub('/', '_'):gsub('=', '')
local auth_url = AUTH_URL
.. '?client_id='
.. M.url_encode(creds.client_id)
.. '&redirect_uri='
.. M.url_encode('http://127.0.0.1:' .. port)
.. '&response_type=code'
.. '&scope='
.. M.url_encode(self.scope)
.. '&access_type=offline'
.. '&prompt=select_account%20consent'
.. '&code_challenge='
.. M.url_encode(code_challenge)
.. '&code_challenge_method=S256'
local server = vim.uv.new_tcp()
local server_closed = false
local function close_server()
if server_closed then
return
end
server_closed = true
if _active_close == close_server then
_active_close = nil
end
server:close()
end
_active_close = close_server
local bind_ok, bind_err = pcall(server.bind, server, '127.0.0.1', port)
if not bind_ok or bind_err == nil then
close_server()
log.error(self.name .. ': Port ' .. port .. ' already in use — try again in a moment.')
return
end
server:listen(1, function(err)
if err then
return
end
local conn = vim.uv.new_tcp()
server:accept(conn)
conn:read_start(function(read_err, data)
if read_err or not data then
conn:close()
close_server()
return
end
local code = data:match('[?&]code=([^&%s]+)')
local response_body = code
and '<html><body><h1>Authorization successful</h1><p>You can close this tab.</p></body></html>'
or '<html><body><h1>Authorization failed</h1></body></html>'
local http_response = 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n'
.. response_body
conn:write(http_response, function()
conn:shutdown(function()
conn:close()
end)
end)
close_server()
if code then
vim.schedule(function()
self:_exchange_code(creds, code, code_verifier, port, on_complete)
end)
end
end)
end)
vim.ui.open(auth_url)
log.info('Opening browser for Google authorization...')
vim.defer_fn(function()
if not server_closed then
close_server()
log.warn('OAuth callback timed out (120s).')
end
end, 120000)
end
---@param creds pending.OAuthCredentials
---@param code string
---@param code_verifier string
---@param port integer
---@param on_complete? fun(): nil
---@return nil
function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complete)
local body = 'client_id='
.. M.url_encode(creds.client_id)
.. '&client_secret='
.. M.url_encode(creds.client_secret)
.. '&code='
.. M.url_encode(code)
.. '&code_verifier='
.. M.url_encode(code_verifier)
.. '&grant_type=authorization_code'
.. '&redirect_uri='
.. M.url_encode('http://127.0.0.1:' .. port)
local result = M.system({
'curl',
'-s',
'-X',
'POST',
'-H',
'Content-Type: application/x-www-form-urlencoded',
'-d',
body,
TOKEN_URL,
}, { text = true })
if result.code ~= 0 then
self:clear_tokens()
log.error('Token exchange failed.')
return
end
local ok, decoded = pcall(vim.json.decode, result.stdout or '')
if not ok or not decoded.access_token then
self:clear_tokens()
log.error('Invalid token response.')
return
end
decoded.obtained_at = os.time()
self:save_tokens(decoded)
log.info(self.name .. ': Authorized successfully.')
if on_complete then
on_complete()
end
end
---@return nil
function OAuthClient:_wipe()
os.remove(self:token_path())
os.remove(vim.fn.stdpath('data') .. '/pending/google_credentials.json')
end
---@return nil
function OAuthClient:clear_tokens()
if _active_close then
_active_close()
_active_close = nil
end
os.remove(self:token_path())
end
---@param opts { name: string, scope: string, port: integer, config_key: string }
---@return pending.OAuthClient
function M.new(opts)
return setmetatable({
name = opts.name,
scope = opts.scope,
port = opts.port,
config_key = opts.config_key,
}, OAuthClient)
end
M._BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID
M._BUNDLED_CLIENT_SECRET = BUNDLED_CLIENT_SECRET
M.BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID
M.google_client = M.new({
name = 'google',
scope = 'https://www.googleapis.com/auth/tasks' .. ' https://www.googleapis.com/auth/calendar',
port = 18392,
config_key = 'google',
})
return M

383
lua/pending/textobj.lua Normal file
View file

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

View file

@ -1,7 +1,8 @@
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'|'filter'
---@field id? integer ---@field id? integer
---@field due? string ---@field due? string
---@field raw_due? string ---@field raw_due? string
@ -10,6 +11,7 @@ local config = require('pending.config')
---@field overdue? boolean ---@field overdue? boolean
---@field show_category? boolean ---@field show_category? boolean
---@field priority? integer ---@field priority? integer
---@field recur? string
---@class pending.views ---@class pending.views
local M = {} local M = {}
@ -20,7 +22,10 @@ local function format_due(due)
if not due then if not due then
return nil return nil
end end
local y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') local y, m, d, hh, mm = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)T(%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
@ -29,7 +34,11 @@ 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]],
}) })
return os.date(config.get().date_format, t) --[[@as string]] local formatted = 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[]
@ -73,7 +82,6 @@ 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 = {}
@ -125,7 +133,7 @@ function M.category_view(tasks)
table.insert(lines, '') table.insert(lines, '')
table.insert(meta, { type = 'blank' }) table.insert(meta, { type = 'blank' })
end end
table.insert(lines, '## ' .. cat) table.insert(lines, '# ' .. cat)
table.insert(meta, { type = 'header', category = cat }) table.insert(meta, { type = 'header', category = cat })
local all = {} local all = {}
@ -148,7 +156,10 @@ 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 task.due < today or nil, priority = task.priority,
overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due)
or nil,
recur = task.recur,
}) })
end end
end end
@ -160,7 +171,6 @@ 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 = {}
@ -198,8 +208,10 @@ 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 task.due < today or nil, priority = task.priority,
overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) or nil,
show_category = true, show_category = true,
recur = task.recur,
}) })
end end

View file

@ -3,16 +3,250 @@ 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 .. ':',
'+!',
'-!',
'-' .. dk,
'-cat',
'-' .. rk,
}
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')
local s = store.new(store.resolve_path())
s:load()
local ids = {}
for _, task in ipairs(s: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')
local s = store.new(store.resolve_path())
s:load()
local seen = {}
local cats = {}
for _, task in ipairs(s: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', 'sync', 'archive', 'due', 'undo' } local pending = require('pending')
local subcmds = { 'add', 'archive', 'auth', 'done', 'due', 'edit', 'filter', 'undo' }
for _, b in ipairs(pending.sync_backends()) do
table.insert(subcmds, b)
end
table.sort(subcmds)
if not cmd_line:match('^Pending%s+%S') then if not cmd_line:match('^Pending%s+%S') then
return vim.tbl_filter(function(s) return filter_candidates(arg_lead, subcmds)
return s:find(arg_lead, 1, true) == 1 end
end, subcmds) if cmd_line:match('^Pending%s+filter') then
local after_filter = cmd_line:match('^Pending%s+filter%s+(.*)') or ''
local used = {}
for word in after_filter:gmatch('%S+') do
used[word] = true
end
local candidates = { 'clear', 'overdue', 'today', 'priority', 'done', 'pending' }
local store = require('pending.store')
local s = store.new(store.resolve_path())
s:load()
local seen = {}
for _, task in ipairs(s:active_tasks()) do
if task.category and not seen[task.category] then
seen[task.category] = true
table.insert(candidates, 'cat:' .. task.category)
end
end
local filtered = {}
for _, c in ipairs(candidates) do
if not used[c] and (arg_lead == '' or c:find(arg_lead, 1, true) == 1) then
table.insert(filtered, c)
end
end
return filtered
end
if cmd_line:match('^Pending%s+done%s') then
local store = require('pending.store')
local s = store.new(store.resolve_path())
s:load()
local ids = {}
for _, task in ipairs(s:active_tasks()) do
table.insert(ids, tostring(task.id))
end
return filter_candidates(arg_lead, ids)
end
if cmd_line:match('^Pending%s+edit') then
return complete_edit(arg_lead, cmd_line)
end
if cmd_line:match('^Pending%s+auth') then
local after_auth = cmd_line:match('^Pending%s+auth%s+(.*)') or ''
local parts = {}
for w in after_auth:gmatch('%S+') do
table.insert(parts, w)
end
local trailing = after_auth:match('%s$')
if #parts == 0 or (#parts == 1 and not trailing) then
return filter_candidates(arg_lead, { 'gcal', 'gtasks', 'clear', 'reset' })
end
if #parts == 1 or (#parts == 2 and not trailing) then
return filter_candidates(arg_lead, { 'clear', 'reset' })
end
return {}
end
local backend_set = pending.sync_backend_set()
local matched_backend = cmd_line:match('^Pending%s+(%S+)')
if matched_backend and backend_set[matched_backend] then
local after_backend = cmd_line:match('^Pending%s+%S+%s+(.*)')
if not after_backend then
return {}
end
local ok, mod = pcall(require, 'pending.sync.' .. matched_backend)
if not ok then
return {}
end
local actions = {}
for k, v in pairs(mod) do
if type(v) == 'function' and k:sub(1, 1) ~= '_' and k ~= 'health' then
table.insert(actions, k)
end
end
table.sort(actions)
return filter_candidates(arg_lead, actions)
end end
return {} return {}
end, end,
@ -22,6 +256,10 @@ 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)
@ -37,3 +275,65 @@ 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-filter)', function()
vim.ui.input({ prompt = 'Filter: ' }, function(input)
if input then
require('pending').filter(input)
end
end)
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-tab)', function()
vim.cmd.tabnew()
require('pending').open()
end)
vim.api.nvim_create_user_command('PendingTab', function()
vim.cmd.tabnew()
require('pending').open()
end, {})

10
scripts/ci.sh Executable file
View file

@ -0,0 +1,10 @@
#!/bin/sh
set -eu
nix develop --command stylua --check .
git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet
nix develop --command prettier --check .
nix fmt
git diff --exit-code -- '*.nix'
nix develop --command lua-language-server --check lua --configpath "$(pwd)/.luarc.json" --checklevel=Warning
nix develop --command busted

View file

@ -1,87 +1,96 @@
require('spec.helpers') require('spec.helpers')
local config = require('pending.config') local config = require('pending.config')
local store = require('pending.store')
describe('archive', function() describe('archive', function()
local tmpdir local tmpdir
local pending = require('pending') local pending
before_each(function() before_each(function()
tmpdir = vim.fn.tempname() tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p') vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' } vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset() config.reset()
store.unload() package.loaded['pending'] = nil
store.load() pending = require('pending')
pending.store():load()
end) end)
after_each(function() after_each(function()
vim.fn.delete(tmpdir, 'rf') vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil vim.g.pending = nil
config.reset() config.reset()
package.loaded['pending'] = nil
end) end)
it('removes done tasks completed more than 30 days ago', function() it('removes done tasks completed more than 30 days ago', function()
local t = store.add({ description = 'Old done task' }) local s = pending.store()
store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) local t = s:add({ description = 'Old done task' })
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive() pending.archive()
assert.are.equal(0, #store.active_tasks()) assert.are.equal(0, #s:active_tasks())
end) end)
it('keeps done tasks completed fewer than 30 days ago', function() it('keeps done tasks completed fewer than 30 days ago', function()
local s = pending.store()
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
local t = store.add({ description = 'Recent done task' }) local t = s:add({ description = 'Recent done task' })
store.update(t.id, { status = 'done', ['end'] = recent_end }) s:update(t.id, { status = 'done', ['end'] = recent_end })
pending.archive() pending.archive()
local active = store.active_tasks() local active = s:active_tasks()
assert.are.equal(1, #active) assert.are.equal(1, #active)
assert.are.equal('Recent done task', active[1].description) assert.are.equal('Recent done task', active[1].description)
end) end)
it('respects a custom day count', function() it('respects a custom day count', function()
local s = pending.store()
local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400)) local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400))
local t = store.add({ description = 'Old for 7 days' }) local t = s:add({ description = 'Old for 7 days' })
store.update(t.id, { status = 'done', ['end'] = eight_days_ago }) s:update(t.id, { status = 'done', ['end'] = eight_days_ago })
pending.archive(7) pending.archive(7)
assert.are.equal(0, #store.active_tasks()) assert.are.equal(0, #s:active_tasks())
end) end)
it('keeps tasks within the custom day cutoff', function() it('keeps tasks within the custom day cutoff', function()
local s = pending.store()
local five_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) local five_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
local t = store.add({ description = 'Recent for 7 days' }) local t = s:add({ description = 'Recent for 7 days' })
store.update(t.id, { status = 'done', ['end'] = five_days_ago }) s:update(t.id, { status = 'done', ['end'] = five_days_ago })
pending.archive(7) pending.archive(7)
local active = store.active_tasks() local active = s:active_tasks()
assert.are.equal(1, #active) assert.are.equal(1, #active)
end) end)
it('never archives pending tasks regardless of age', function() it('never archives pending tasks regardless of age', function()
store.add({ description = 'Still pending' }) local s = pending.store()
s:add({ description = 'Still pending' })
pending.archive() pending.archive()
local active = store.active_tasks() local active = s:active_tasks()
assert.are.equal(1, #active) assert.are.equal(1, #active)
assert.are.equal('pending', active[1].status) assert.are.equal('pending', active[1].status)
end) end)
it('removes deleted tasks past the cutoff', function() it('removes deleted tasks past the cutoff', function()
local t = store.add({ description = 'Old deleted task' }) local s = pending.store()
store.update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' }) local t = s:add({ description = 'Old deleted task' })
s:update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive() pending.archive()
local all = store.tasks() local all = s:tasks()
assert.are.equal(0, #all) assert.are.equal(0, #all)
end) end)
it('keeps deleted tasks within the cutoff', function() it('keeps deleted tasks within the cutoff', function()
local s = pending.store()
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
local t = store.add({ description = 'Recent deleted' }) local t = s:add({ description = 'Recent deleted' })
store.update(t.id, { status = 'deleted', ['end'] = recent_end }) s:update(t.id, { status = 'deleted', ['end'] = recent_end })
pending.archive() pending.archive()
local all = store.tasks() local all = s:tasks()
assert.are.equal(1, #all) assert.are.equal(1, #all)
end) end)
it('reports the correct count in vim.notify', function() it('reports the correct count in vim.notify', function()
local s = pending.store()
local messages = {} local messages = {}
local orig_notify = vim.notify local orig_notify = vim.notify
vim.notify = function(msg, ...) vim.notify = function(msg, ...)
@ -89,11 +98,11 @@ describe('archive', function()
return orig_notify(msg, ...) return orig_notify(msg, ...)
end end
local t1 = store.add({ description = 'Old 1' }) local t1 = s:add({ description = 'Old 1' })
local t2 = store.add({ description = 'Old 2' }) local t2 = s:add({ description = 'Old 2' })
store.add({ description = 'Keep' }) s:add({ description = 'Keep' })
store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) s:update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
store.update(t2.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) s:update(t2.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive() pending.archive()
@ -110,16 +119,17 @@ describe('archive', function()
end) end)
it('leaves only kept tasks in store.active_tasks after archive', function() it('leaves only kept tasks in store.active_tasks after archive', function()
local t1 = store.add({ description = 'Old done' }) local s = pending.store()
store.add({ description = 'Keep pending' }) local t1 = s:add({ description = 'Old done' })
s:add({ description = 'Keep pending' })
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
local t3 = store.add({ description = 'Keep recent done' }) local t3 = s:add({ description = 'Keep recent done' })
store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) s:update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
store.update(t3.id, { status = 'done', ['end'] = recent_end }) s:update(t3.id, { status = 'done', ['end'] = recent_end })
pending.archive() pending.archive()
local active = store.active_tasks() local active = s:active_tasks()
assert.are.equal(2, #active) assert.are.equal(2, #active)
local descs = {} local descs = {}
for _, task in ipairs(active) do for _, task in ipairs(active) do
@ -130,11 +140,11 @@ describe('archive', function()
end) end)
it('persists archived tasks to disk after unload/reload', function() it('persists archived tasks to disk after unload/reload', function()
local t = store.add({ description = 'Archived task' }) local s = pending.store()
store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) local t = s:add({ description = 'Archived task' })
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive() pending.archive()
store.unload() s:load()
store.load() assert.are.equal(0, #s:active_tasks())
assert.are.equal(0, #store.active_tasks())
end) end)
end) end)

173
spec/complete_spec.lua Normal file
View file

@ -0,0 +1,173 @@
require('spec.helpers')
local buffer = require('pending.buffer')
local config = require('pending.config')
local store = require('pending.store')
describe('complete', function()
local tmpdir
local s
local complete = require('pending.complete')
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
config.reset()
s = store.new(tmpdir .. '/tasks.json')
s:load()
buffer.set_store(s)
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
config.reset()
buffer.set_store(nil)
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()
s:add({ description = 'A', category = 'Work' })
s:add({ description = 'B', category = 'Home' })
s: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()
s:add({ description = 'A', category = 'Work' })
s: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

@ -1,35 +1,31 @@
require('spec.helpers') require('spec.helpers')
local config = require('pending.config')
local store = require('pending.store') local store = require('pending.store')
describe('diff', function() describe('diff', function()
local tmpdir local tmpdir
local s
local diff = require('pending.diff') local diff = require('pending.diff')
before_each(function() before_each(function()
tmpdir = vim.fn.tempname() tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p') vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' } s = store.new(tmpdir .. '/tasks.json')
config.reset() s:load()
store.unload()
store.load()
end) end)
after_each(function() after_each(function()
vim.fn.delete(tmpdir, 'rf') vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
end) end)
describe('parse_buffer', function() describe('parse_buffer', function()
it('parses headers and tasks', function() it('parses headers and tasks', function()
local lines = { local lines = {
'## School', '# School',
'/1/- [ ] Do homework', '/1/- [ ] Do homework',
'/2/- [!] Read chapter 5', '/2/- [!] Read chapter 5',
'', '',
'## Errands', '# Errands',
'/3/- [ ] Buy groceries', '/3/- [ ] Buy groceries',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
@ -48,7 +44,7 @@ describe('diff', function()
it('handles new tasks without ids', function() it('handles new tasks without ids', function()
local lines = { local lines = {
'## Inbox', '# Inbox',
'- [ ] New task here', '- [ ] New task here',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
@ -60,7 +56,7 @@ describe('diff', function()
it('inline cat: token overrides header category', function() it('inline cat: token overrides header category', function()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Buy milk cat:Work', '/1/- [ ] Buy milk cat:Work',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
@ -69,9 +65,28 @@ 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',
'/1/- [ ] Buy milk due:2026-03-15', '/1/- [ ] Buy milk due:2026-03-15',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
@ -84,139 +99,192 @@ describe('diff', function()
describe('apply', function() describe('apply', function()
it('creates new tasks from buffer lines', function() it('creates new tasks from buffer lines', function()
local lines = { local lines = {
'## Inbox', '# Inbox',
'- [ ] First task', '- [ ] First task',
'- [ ] Second task', '- [ ] Second task',
} }
diff.apply(lines) diff.apply(lines, s)
store.unload() s:load()
store.load() local tasks = s:active_tasks()
local tasks = store.active_tasks()
assert.are.equal(2, #tasks) assert.are.equal(2, #tasks)
assert.are.equal('First task', tasks[1].description) assert.are.equal('First task', tasks[1].description)
assert.are.equal('Second task', tasks[2].description) assert.are.equal('Second task', tasks[2].description)
end) end)
it('deletes tasks removed from buffer', function() it('deletes tasks removed from buffer', function()
store.add({ description = 'Keep me' }) s:add({ description = 'Keep me' })
store.add({ description = 'Delete me' }) s:add({ description = 'Delete me' })
store.save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Keep me', '/1/- [ ] Keep me',
} }
diff.apply(lines) diff.apply(lines, s)
store.unload() s:load()
store.load() local active = s:active_tasks()
local active = store.active_tasks()
assert.are.equal(1, #active) assert.are.equal(1, #active)
assert.are.equal('Keep me', active[1].description) assert.are.equal('Keep me', active[1].description)
local deleted = store.get(2) local deleted = s:get(2)
assert.are.equal('deleted', deleted.status) assert.are.equal('deleted', deleted.status)
end) end)
it('updates modified tasks', function() it('updates modified tasks', function()
store.add({ description = 'Original' }) s:add({ description = 'Original' })
store.save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Renamed', '/1/- [ ] Renamed',
} }
diff.apply(lines) diff.apply(lines, s)
store.unload() s:load()
store.load() local task = s:get(1)
local task = store.get(1)
assert.are.equal('Renamed', task.description) assert.are.equal('Renamed', task.description)
end) end)
it('updates modified when description is renamed', function() it('updates modified when description is renamed', function()
local t = store.add({ description = 'Original', category = 'Inbox' }) local t = s:add({ description = 'Original', category = 'Inbox' })
t.modified = '2020-01-01T00:00:00Z' t.modified = '2020-01-01T00:00:00Z'
store.save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Renamed', '/1/- [ ] Renamed',
} }
diff.apply(lines) diff.apply(lines, s)
store.unload() s:load()
store.load() local task = s:get(1)
local task = store.get(1)
assert.are.equal('Renamed', task.description) assert.are.equal('Renamed', task.description)
assert.is_not.equal('2020-01-01T00:00:00Z', task.modified) assert.is_not.equal('2020-01-01T00:00:00Z', task.modified)
end) end)
it('handles duplicate ids as copies', function() it('handles duplicate ids as copies', function()
store.add({ description = 'Original' }) s:add({ description = 'Original' })
store.save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Original', '/1/- [ ] Original',
'/1/- [ ] Copy of original', '/1/- [ ] Copy of original',
} }
diff.apply(lines) diff.apply(lines, s)
store.unload() s:load()
store.load() local tasks = s:active_tasks()
local tasks = store.active_tasks()
assert.are.equal(2, #tasks) assert.are.equal(2, #tasks)
end) end)
it('moves tasks between categories', function() it('moves tasks between categories', function()
store.add({ description = 'Moving task', category = 'Inbox' }) s:add({ description = 'Moving task', category = 'Inbox' })
store.save() s:save()
local lines = { local lines = {
'## Work', '# Work',
'/1/- [ ] Moving task', '/1/- [ ] Moving task',
} }
diff.apply(lines) diff.apply(lines, s)
store.unload() s:load()
store.load() local task = s:get(1)
local task = store.get(1)
assert.are.equal('Work', task.category) assert.are.equal('Work', task.category)
end) end)
it('does not update modified when task is unchanged', function() it('does not update modified when task is unchanged', function()
store.add({ description = 'Stable task', category = 'Inbox' }) s:add({ description = 'Stable task', category = 'Inbox' })
store.save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Stable task', '/1/- [ ] Stable task',
} }
diff.apply(lines) diff.apply(lines, s)
store.unload() s:load()
store.load() local modified_after_first = s:get(1).modified
local modified_after_first = store.get(1).modified diff.apply(lines, s)
diff.apply(lines) s:load()
store.unload() local task = s:get(1)
store.load()
local task = store.get(1)
assert.are.equal(modified_after_first, task.modified) assert.are.equal(modified_after_first, task.modified)
end) end)
it('clears due when removed from buffer line', function() it('preserves due when not present in buffer line', function()
store.add({ description = 'Pay bill', due = '2026-03-15' }) s:add({ description = 'Pay bill', due = '2026-03-15' })
store.save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Pay bill', '/1/- [ ] Pay bill',
} }
diff.apply(lines) diff.apply(lines, s)
store.unload() s:load()
store.load() local task = s:get(1)
local task = store.get(1) assert.are.equal('2026-03-15', task.due)
assert.is_nil(task.due) end)
it('updates due when inline token is present', function()
s:add({ description = 'Pay bill', due = '2026-03-15' })
s:save()
local lines = {
'# Inbox',
'/1/- [ ] Pay bill due:2026-04-01',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('2026-04-01', task.due)
end)
it('stores recur field on new tasks from buffer', function()
local lines = {
'# Inbox',
'- [ ] Take out trash rec:weekly',
}
diff.apply(lines, s)
s:load()
local tasks = s:active_tasks()
assert.are.equal(1, #tasks)
assert.are.equal('weekly', tasks[1].recur)
end)
it('updates recur field when changed inline', function()
s:add({ description = 'Task', recur = 'daily' })
s:save()
local lines = {
'# Todo',
'/1/- [ ] Task rec:weekly',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('weekly', task.recur)
end)
it('preserves recur when not present in buffer line', function()
s:add({ description = 'Task', recur = 'daily' })
s:save()
local lines = {
'# Todo',
'/1/- [ ] Task',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('daily', task.recur)
end)
it('parses rec: with completion mode prefix', function()
local lines = {
'# Inbox',
'- [ ] Water plants rec:!weekly',
}
diff.apply(lines, s)
s:load()
local tasks = s:active_tasks()
assert.are.equal('weekly', tasks[1].recur)
assert.are.equal('completion', tasks[1].recur_mode)
end) 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 }) s:add({ description = 'Task name', priority = 1 })
store.save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Task name', '/1/- [ ] Task name',
} }
diff.apply(lines) diff.apply(lines, s)
store.unload() s:load()
store.load() local task = s:get(1)
local task = store.get(1)
assert.are.equal(0, task.priority) assert.are.equal(0, task.priority)
end) end)
end) end)

329
spec/edit_spec.lua Normal file
View file

@ -0,0 +1,329 @@
require('spec.helpers')
local config = require('pending.config')
describe('edit', 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()
package.loaded['pending'] = nil
pending = require('pending')
pending.store():load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
package.loaded['pending'] = nil
end)
it('sets due date with resolve_date vocabulary', function()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'due:tomorrow')
local updated = s: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 s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'due:2026-06-15')
local updated = s:get(t.id)
assert.are.equal('2026-06-15', updated.due)
end)
it('sets category', function()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'cat:Work')
local updated = s:get(t.id)
assert.are.equal('Work', updated.category)
end)
it('adds priority', function()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), '+!')
local updated = s:get(t.id)
assert.are.equal(1, updated.priority)
end)
it('removes priority', function()
local s = pending.store()
local t = s:add({ description = 'Task one', priority = 1 })
s:save()
pending.edit(tostring(t.id), '-!')
local updated = s:get(t.id)
assert.are.equal(0, updated.priority)
end)
it('removes due date', function()
local s = pending.store()
local t = s:add({ description = 'Task one', due = '2026-06-15' })
s:save()
pending.edit(tostring(t.id), '-due')
local updated = s:get(t.id)
assert.is_nil(updated.due)
end)
it('removes category', function()
local s = pending.store()
local t = s:add({ description = 'Task one', category = 'Work' })
s:save()
pending.edit(tostring(t.id), '-cat')
local updated = s:get(t.id)
assert.is_nil(updated.category)
end)
it('sets recurrence', function()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'rec:weekly')
local updated = s:get(t.id)
assert.are.equal('weekly', updated.recur)
assert.is_nil(updated.recur_mode)
end)
it('sets completion-based recurrence', function()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'rec:!daily')
local updated = s:get(t.id)
assert.are.equal('daily', updated.recur)
assert.are.equal('completion', updated.recur_mode)
end)
it('removes recurrence', function()
local s = pending.store()
local t = s:add({ description = 'Task one', recur = 'weekly', recur_mode = 'scheduled' })
s:save()
pending.edit(tostring(t.id), '-rec')
local updated = s:get(t.id)
assert.is_nil(updated.recur)
assert.is_nil(updated.recur_mode)
end)
it('applies multiple operations at once', function()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'due:today cat:Errands +!')
local updated = s: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 s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
local stack_before = #s:undo_stack()
pending.edit(tostring(t.id), 'cat:Work')
assert.are.equal(stack_before + 1, #s:undo_stack())
end)
it('persists changes to disk', function()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'cat:Work')
s:load()
local updated = s:get(t.id)
assert.are.equal('Work', updated.category)
end)
it('errors on unknown task ID', function()
local s = pending.store()
s:add({ description = 'Task one' })
s: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 s = pending.store()
local t = s:add({ description = 'Task one' })
s: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 s = pending.store()
local t = s:add({ description = 'Task one' })
s: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 s = pending.store()
local t = s:add({ description = 'Task one' })
s: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 s = pending.store()
local t = s:add({ description = 'Task one' })
s: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 s = pending.store()
local t = s:add({ description = 'Task one' })
s: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()
package.loaded['pending'] = nil
pending = require('pending')
local s = pending.store()
s:load()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'by:tomorrow')
local updated = s: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()
package.loaded['pending'] = nil
pending = require('pending')
local s = pending.store()
s:load()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'repeat:weekly')
local updated = s:get(t.id)
assert.are.equal('weekly', updated.recur)
end)
it('does not modify store on error', function()
local s = pending.store()
local t = s:add({ description = 'Task one', category = 'Original' })
s:save()
local orig_notify = vim.notify
vim.notify = function() end
pending.edit(tostring(t.id), 'due:notadate')
vim.notify = orig_notify
local updated = s:get(t.id)
assert.are.equal('Original', updated.category)
assert.is_nil(updated.due)
end)
it('sets due date with datetime format', function()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'due:tomorrow@14:00')
local updated = s: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)

292
spec/filter_spec.lua Normal file
View file

@ -0,0 +1,292 @@
require('spec.helpers')
local config = require('pending.config')
local diff = require('pending.diff')
describe('filter', function()
local tmpdir
local pending
local buffer
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.buffer'] = nil
pending = require('pending')
buffer = require('pending.buffer')
buffer.set_filter({}, {})
pending.store():load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.buffer'] = nil
end)
describe('filter predicates', function()
it('cat: hides tasks with non-matching category', function()
local s = pending.store()
s:add({ description = 'Work task', category = 'Work' })
s:add({ description = 'Home task', category = 'Home' })
s:save()
pending.filter('cat:Work')
local hidden = buffer.hidden_ids()
local tasks = s:active_tasks()
local work_task = nil
local home_task = nil
for _, t in ipairs(tasks) do
if t.category == 'Work' then
work_task = t
end
if t.category == 'Home' then
home_task = t
end
end
assert.is_not_nil(work_task)
assert.is_not_nil(home_task)
assert.is_nil(hidden[work_task.id])
assert.is_true(hidden[home_task.id])
end)
it('cat: hides tasks with no category (default category)', function()
local s = pending.store()
s:add({ description = 'Work task', category = 'Work' })
s:add({ description = 'Inbox task' })
s:save()
pending.filter('cat:Work')
local hidden = buffer.hidden_ids()
local tasks = s:active_tasks()
local inbox_task = nil
for _, t in ipairs(tasks) do
if t.category ~= 'Work' then
inbox_task = t
end
end
assert.is_not_nil(inbox_task)
assert.is_true(hidden[inbox_task.id])
end)
it('overdue hides non-overdue tasks', function()
local s = pending.store()
s:add({ description = 'Old task', due = '2020-01-01' })
s:add({ description = 'Future task', due = '2099-01-01' })
s:add({ description = 'No due task' })
s:save()
pending.filter('overdue')
local hidden = buffer.hidden_ids()
local tasks = s:active_tasks()
local overdue_task, future_task, nodue_task
for _, t in ipairs(tasks) do
if t.due == '2020-01-01' then
overdue_task = t
end
if t.due == '2099-01-01' then
future_task = t
end
if not t.due then
nodue_task = t
end
end
assert.is_nil(hidden[overdue_task.id])
assert.is_true(hidden[future_task.id])
assert.is_true(hidden[nodue_task.id])
end)
it('today hides non-today tasks', function()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
s:add({ description = 'Today task', due = today })
s:add({ description = 'Old task', due = '2020-01-01' })
s:add({ description = 'Future task', due = '2099-01-01' })
s:save()
pending.filter('today')
local hidden = buffer.hidden_ids()
local tasks = s:active_tasks()
local today_task, old_task, future_task
for _, t in ipairs(tasks) do
if t.due == today then
today_task = t
end
if t.due == '2020-01-01' then
old_task = t
end
if t.due == '2099-01-01' then
future_task = t
end
end
assert.is_nil(hidden[today_task.id])
assert.is_true(hidden[old_task.id])
assert.is_true(hidden[future_task.id])
end)
it('priority hides non-priority tasks', function()
local s = pending.store()
s:add({ description = 'Important', priority = 1 })
s:add({ description = 'Normal' })
s:save()
pending.filter('priority')
local hidden = buffer.hidden_ids()
local tasks = s:active_tasks()
local important_task, normal_task
for _, t in ipairs(tasks) do
if t.priority and t.priority > 0 then
important_task = t
end
if not t.priority or t.priority == 0 then
normal_task = t
end
end
assert.is_nil(hidden[important_task.id])
assert.is_true(hidden[normal_task.id])
end)
it('multi-predicate AND: cat:Work + overdue', function()
local s = pending.store()
s:add({ description = 'Work overdue', category = 'Work', due = '2020-01-01' })
s:add({ description = 'Work future', category = 'Work', due = '2099-01-01' })
s:add({ description = 'Home overdue', category = 'Home', due = '2020-01-01' })
s:save()
pending.filter('cat:Work overdue')
local hidden = buffer.hidden_ids()
local tasks = s:active_tasks()
local work_overdue, work_future, home_overdue
for _, t in ipairs(tasks) do
if t.description == 'Work overdue' then
work_overdue = t
end
if t.description == 'Work future' then
work_future = t
end
if t.description == 'Home overdue' then
home_overdue = t
end
end
assert.is_nil(hidden[work_overdue.id])
assert.is_true(hidden[work_future.id])
assert.is_true(hidden[home_overdue.id])
end)
it('filter clear removes all predicates and hidden ids', function()
local s = pending.store()
s:add({ description = 'Work task', category = 'Work' })
s:add({ description = 'Home task', category = 'Home' })
s:save()
pending.filter('cat:Work')
assert.are.equal(1, #buffer.filter_predicates())
pending.filter('clear')
assert.are.equal(0, #buffer.filter_predicates())
assert.are.same({}, buffer.hidden_ids())
end)
it('filter empty string clears filter', function()
local s = pending.store()
s:add({ description = 'Work task', category = 'Work' })
s:save()
pending.filter('cat:Work')
assert.are.equal(1, #buffer.filter_predicates())
pending.filter('')
assert.are.equal(0, #buffer.filter_predicates())
end)
it('filter predicates persist across set_filter calls', function()
local s = pending.store()
s:add({ description = 'Work task', category = 'Work' })
s:add({ description = 'Home task', category = 'Home' })
s:save()
pending.filter('cat:Work')
local preds = buffer.filter_predicates()
assert.are.equal(1, #preds)
assert.are.equal('cat:Work', preds[1])
local hidden = buffer.hidden_ids()
local tasks = s:active_tasks()
local home_task
for _, t in ipairs(tasks) do
if t.category == 'Home' then
home_task = t
end
end
assert.is_true(hidden[home_task.id])
end)
end)
describe('diff.apply with hidden_ids', function()
it('does not mark hidden tasks as deleted', function()
local s = pending.store()
s:add({ description = 'Visible task' })
s:add({ description = 'Hidden task' })
s:save()
local tasks = s:active_tasks()
local hidden_task
for _, t in ipairs(tasks) do
if t.description == 'Hidden task' then
hidden_task = t
end
end
local hidden_ids = { [hidden_task.id] = true }
local lines = {
'/1/- [ ] Visible task',
}
diff.apply(lines, s, hidden_ids)
s:load()
local hidden = s:get(hidden_task.id)
assert.are.equal('pending', hidden.status)
end)
it('marks tasks deleted when not hidden and not in buffer', function()
local s = pending.store()
s:add({ description = 'Keep task' })
s:add({ description = 'Delete task' })
s:save()
local tasks = s:active_tasks()
local keep_task, delete_task
for _, t in ipairs(tasks) do
if t.description == 'Keep task' then
keep_task = t
end
if t.description == 'Delete task' then
delete_task = t
end
end
local lines = {
'/' .. keep_task.id .. '/- [ ] Keep task',
}
diff.apply(lines, s, {})
s:load()
local deleted = s:get(delete_task.id)
assert.are.equal('deleted', deleted.status)
end)
it('strips FILTER: line before parsing', function()
local s = pending.store()
s:add({ description = 'My task' })
s:save()
local tasks = s:active_tasks()
local task = tasks[1]
local lines = {
'FILTER: cat:Work',
'/' .. task.id .. '/- [ ] My task',
}
diff.apply(lines, s, {})
s:load()
local t = s:get(task.id)
assert.are.equal('pending', t.status)
end)
it('parse_buffer skips FILTER: header line', function()
local lines = {
'FILTER: overdue',
'/1/- [ ] A task',
}
local result = diff.parse_buffer(lines)
assert.are.equal(1, #result)
assert.are.equal('task', result[1].type)
assert.are.equal('A task', result[1].description)
end)
end)
end)

368
spec/gtasks_spec.lua Normal file
View file

@ -0,0 +1,368 @@
require('spec.helpers')
local gtasks = require('pending.sync.gtasks')
describe('gtasks field conversion', function()
describe('due date helpers', function()
it('converts date-only to RFC 3339', function()
assert.equals('2026-03-15T00:00:00.000Z', gtasks._due_to_rfc3339('2026-03-15'))
end)
it('converts datetime to RFC 3339 (strips time)', function()
assert.equals('2026-03-15T00:00:00.000Z', gtasks._due_to_rfc3339('2026-03-15T14:30'))
end)
it('strips RFC 3339 to date-only', function()
assert.equals('2026-03-15', gtasks._rfc3339_to_date('2026-03-15T00:00:00.000Z'))
end)
end)
describe('build_notes', function()
it('returns nil when no priority or recur', function()
assert.is_nil(gtasks._build_notes({ priority = 0, recur = nil }))
end)
it('encodes priority', function()
assert.equals('pri:1', gtasks._build_notes({ priority = 1, recur = nil }))
end)
it('encodes recur', function()
assert.equals('rec:weekly', gtasks._build_notes({ priority = 0, recur = 'weekly' }))
end)
it('encodes completion-mode recur with ! prefix', function()
assert.equals(
'rec:!daily',
gtasks._build_notes({ priority = 0, recur = 'daily', recur_mode = 'completion' })
)
end)
it('encodes both priority and recur', function()
assert.equals('pri:1 rec:weekly', gtasks._build_notes({ priority = 1, recur = 'weekly' }))
end)
end)
describe('parse_notes', function()
it('returns zeros/nils for nil input', function()
local pri, rec, mode = gtasks._parse_notes(nil)
assert.equals(0, pri)
assert.is_nil(rec)
assert.is_nil(mode)
end)
it('parses priority', function()
local pri = gtasks._parse_notes('pri:1')
assert.equals(1, pri)
end)
it('parses recur', function()
local _, rec = gtasks._parse_notes('rec:weekly')
assert.equals('weekly', rec)
end)
it('parses completion-mode recur', function()
local _, rec, mode = gtasks._parse_notes('rec:!daily')
assert.equals('daily', rec)
assert.equals('completion', mode)
end)
it('parses both priority and recur', function()
local pri, rec = gtasks._parse_notes('pri:1 rec:monthly')
assert.equals(1, pri)
assert.equals('monthly', rec)
end)
it('round-trips through build_notes', function()
local task = { priority = 1, recur = 'weekly', recur_mode = nil }
local notes = gtasks._build_notes(task)
local pri, rec = gtasks._parse_notes(notes)
assert.equals(1, pri)
assert.equals('weekly', rec)
end)
end)
describe('task_to_gtask', function()
it('maps description to title', function()
local body = gtasks._task_to_gtask({
description = 'Buy milk',
status = 'pending',
priority = 0,
})
assert.equals('Buy milk', body.title)
end)
it('maps pending status to needsAction', function()
local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 0 })
assert.equals('needsAction', body.status)
end)
it('maps done status to completed', function()
local body = gtasks._task_to_gtask({ description = 'x', status = 'done', priority = 0 })
assert.equals('completed', body.status)
end)
it('converts due date to RFC 3339', function()
local body = gtasks._task_to_gtask({
description = 'x',
status = 'pending',
priority = 0,
due = '2026-03-15',
})
assert.equals('2026-03-15T00:00:00.000Z', body.due)
end)
it('omits due when nil', function()
local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 0 })
assert.is_nil(body.due)
end)
it('includes notes when priority is set', function()
local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 1 })
assert.equals('pri:1', body.notes)
end)
it('omits notes when no extra fields', function()
local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 0 })
assert.is_nil(body.notes)
end)
end)
describe('gtask_to_fields', function()
it('maps title to description', function()
local fields = gtasks._gtask_to_fields({ title = 'Buy milk', status = 'needsAction' }, 'Work')
assert.equals('Buy milk', fields.description)
end)
it('maps category from list name', function()
local fields = gtasks._gtask_to_fields({ title = 'x', status = 'needsAction' }, 'Personal')
assert.equals('Personal', fields.category)
end)
it('maps needsAction to pending', function()
local fields = gtasks._gtask_to_fields({ title = 'x', status = 'needsAction' }, 'Work')
assert.equals('pending', fields.status)
end)
it('maps completed to done', function()
local fields = gtasks._gtask_to_fields({ title = 'x', status = 'completed' }, 'Work')
assert.equals('done', fields.status)
end)
it('strips due date to YYYY-MM-DD', function()
local fields = gtasks._gtask_to_fields({
title = 'x',
status = 'needsAction',
due = '2026-03-15T00:00:00.000Z',
}, 'Work')
assert.equals('2026-03-15', fields.due)
end)
it('parses priority from notes', function()
local fields = gtasks._gtask_to_fields({
title = 'x',
status = 'needsAction',
notes = 'pri:1',
}, 'Work')
assert.equals(1, fields.priority)
end)
it('parses recur from notes', function()
local fields = gtasks._gtask_to_fields({
title = 'x',
status = 'needsAction',
notes = 'rec:weekly',
}, 'Work')
assert.equals('weekly', fields.recur)
end)
end)
end)
describe('gtasks push_pass _gtasks_synced_at', function()
local helpers = require('spec.helpers')
local store_mod = require('pending.store')
local oauth = require('pending.sync.oauth')
local s
local orig_curl
before_each(function()
local dir = helpers.tmpdir()
s = store_mod.new(dir .. '/pending.json')
s:load()
orig_curl = oauth.curl_request
end)
after_each(function()
oauth.curl_request = orig_curl
end)
it('sets _gtasks_synced_at after push create', function()
local task =
s:add({ description = 'New task', status = 'pending', category = 'Work', priority = 0 })
oauth.curl_request = function(method, url, _headers, _body)
if method == 'POST' and url:find('/tasks$') then
return { id = 'gtask-new-1' }, nil
end
return {}, nil
end
local now_ts = '2026-03-05T10:00:00Z'
local tasklists = { Work = 'list-1' }
local by_id = {}
gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id)
assert.is_not_nil(task._extra)
assert.equals('2026-03-05T10:00:00Z', task._extra['_gtasks_synced_at'])
end)
it('skips update when modified <= _gtasks_synced_at', function()
local task =
s:add({ description = 'Existing task', status = 'pending', category = 'Work', priority = 0 })
task._extra = {
_gtasks_task_id = 'remote-1',
_gtasks_list_id = 'list-1',
_gtasks_synced_at = '2026-03-05T10:00:00Z',
}
task.modified = '2026-03-05T09:00:00Z'
local patch_called = false
oauth.curl_request = function(method, _url, _headers, _body)
if method == 'PATCH' then
patch_called = true
end
return {}, nil
end
local now_ts = '2026-03-05T11:00:00Z'
local tasklists = { Work = 'list-1' }
local by_id = { ['remote-1'] = task }
gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id)
assert.is_false(patch_called)
end)
it('pushes update when modified > _gtasks_synced_at', function()
local task =
s:add({ description = 'Changed task', status = 'pending', category = 'Work', priority = 0 })
task._extra = {
_gtasks_task_id = 'remote-2',
_gtasks_list_id = 'list-1',
_gtasks_synced_at = '2026-03-05T08:00:00Z',
}
task.modified = '2026-03-05T09:00:00Z'
local patch_called = false
oauth.curl_request = function(method, _url, _headers, _body)
if method == 'PATCH' then
patch_called = true
end
return {}, nil
end
local now_ts = '2026-03-05T11:00:00Z'
local tasklists = { Work = 'list-1' }
local by_id = { ['remote-2'] = task }
gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id)
assert.is_true(patch_called)
end)
it('pushes update when no _gtasks_synced_at (backwards compat)', function()
local task =
s:add({ description = 'Old task', status = 'pending', category = 'Work', priority = 0 })
task._extra = {
_gtasks_task_id = 'remote-3',
_gtasks_list_id = 'list-1',
}
task.modified = '2026-01-01T00:00:00Z'
local patch_called = false
oauth.curl_request = function(method, _url, _headers, _body)
if method == 'PATCH' then
patch_called = true
end
return {}, nil
end
local now_ts = '2026-03-05T11:00:00Z'
local tasklists = { Work = 'list-1' }
local by_id = { ['remote-3'] = task }
gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id)
assert.is_true(patch_called)
end)
end)
describe('gtasks detect_remote_deletions', function()
local helpers = require('spec.helpers')
local store_mod = require('pending.store')
local s
before_each(function()
local dir = helpers.tmpdir()
s = store_mod.new(dir .. '/pending.json')
s:load()
end)
it('clears remote IDs when list was fetched but task ID is absent', function()
local task =
s:add({ description = 'Gone remote', status = 'pending', category = 'Work', priority = 0 })
task._extra = {
_gtasks_task_id = 'old-remote-id',
_gtasks_list_id = 'list-1',
_gtasks_synced_at = '2026-01-01T00:00:00Z',
}
local seen = {}
local fetched = { ['list-1'] = true }
local now_ts = '2026-03-05T10:00:00Z'
local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts)
assert.equals(1, unlinked)
assert.is_nil(task._extra)
assert.equals('2026-03-05T10:00:00Z', task.modified)
end)
it('leaves task untouched when its list fetch failed', function()
local task = s:add({
description = 'Unknown list task',
status = 'pending',
category = 'Work',
priority = 0,
})
task._extra = {
_gtasks_task_id = 'remote-id',
_gtasks_list_id = 'list-unfetched',
}
local seen = {}
local fetched = {}
local now_ts = '2026-03-05T10:00:00Z'
local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts)
assert.equals(0, unlinked)
assert.is_not_nil(task._extra)
assert.equals('remote-id', task._extra['_gtasks_task_id'])
end)
it('skips tasks with status == deleted', function()
local task =
s:add({ description = 'Deleted task', status = 'deleted', category = 'Work', priority = 0 })
task._extra = {
_gtasks_task_id = 'remote-del',
_gtasks_list_id = 'list-1',
}
local seen = {}
local fetched = { ['list-1'] = true }
local now_ts = '2026-03-05T10:00:00Z'
local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts)
assert.equals(0, unlinked)
assert.is_not_nil(task._extra)
assert.equals('remote-del', task._extra['_gtasks_task_id'])
end)
end)

56
spec/icons_spec.lua Normal file
View file

@ -0,0 +1,56 @@
require('spec.helpers')
local config = require('pending.config')
describe('icons', function()
before_each(function()
vim.g.pending = nil
config.reset()
end)
after_each(function()
vim.g.pending = nil
config.reset()
end)
it('has default icon values', function()
local icons = config.get().icons
assert.equals(' ', icons.pending)
assert.equals('x', icons.done)
assert.equals('!', icons.priority)
assert.equals('.', icons.due)
assert.equals('~', icons.recur)
assert.equals('#', icons.category)
end)
it('allows overriding individual icons', function()
vim.g.pending = { icons = { pending = '*', done = '+' } }
config.reset()
local icons = config.get().icons
assert.equals('*', icons.pending)
assert.equals('+', icons.done)
assert.equals('!', icons.priority)
assert.equals('#', icons.category)
end)
it('allows overriding all icons', function()
vim.g.pending = {
icons = {
pending = '-',
done = '+',
priority = '*',
due = '@',
recur = '^',
category = '&',
},
}
config.reset()
local icons = config.get().icons
assert.equals('-', icons.pending)
assert.equals('+', icons.done)
assert.equals('*', icons.priority)
assert.equals('@', icons.due)
assert.equals('^', icons.recur)
assert.equals('&', icons.category)
end)
end)

235
spec/oauth_spec.lua Normal file
View file

@ -0,0 +1,235 @@
require('spec.helpers')
local config = require('pending.config')
local oauth = require('pending.sync.oauth')
describe('oauth', 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()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
end)
describe('url_encode', function()
it('leaves alphanumerics unchanged', function()
assert.equals('hello123', oauth.url_encode('hello123'))
end)
it('encodes spaces', function()
assert.equals('hello%20world', oauth.url_encode('hello world'))
end)
it('encodes special characters', function()
assert.equals('a%3Db%26c', oauth.url_encode('a=b&c'))
end)
it('preserves hyphens, dots, underscores, tildes', function()
assert.equals('a-b.c_d~e', oauth.url_encode('a-b.c_d~e'))
end)
end)
describe('load_json_file', function()
it('returns nil for missing file', function()
assert.is_nil(oauth.load_json_file(tmpdir .. '/nonexistent.json'))
end)
it('returns nil for empty file', function()
local path = tmpdir .. '/empty.json'
local f = io.open(path, 'w')
f:write('')
f:close()
assert.is_nil(oauth.load_json_file(path))
end)
it('returns nil for invalid JSON', function()
local path = tmpdir .. '/bad.json'
local f = io.open(path, 'w')
f:write('not json')
f:close()
assert.is_nil(oauth.load_json_file(path))
end)
it('parses valid JSON', function()
local path = tmpdir .. '/good.json'
local f = io.open(path, 'w')
f:write('{"key":"value"}')
f:close()
local data = oauth.load_json_file(path)
assert.equals('value', data.key)
end)
end)
describe('save_json_file', function()
it('creates parent directories', function()
local path = tmpdir .. '/sub/dir/file.json'
local ok = oauth.save_json_file(path, { test = true })
assert.is_true(ok)
local data = oauth.load_json_file(path)
assert.is_true(data.test)
end)
it('sets restrictive permissions', function()
local path = tmpdir .. '/secret.json'
oauth.save_json_file(path, { x = 1 })
local perms = vim.fn.getfperm(path)
assert.equals('rw-------', perms)
end)
end)
describe('resolve_credentials', function()
it('uses config fields when set', function()
config.reset()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
sync = {
gtasks = {
client_id = 'config-id',
client_secret = 'config-secret',
},
},
}
local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' })
local creds = c:resolve_credentials()
assert.equals('config-id', creds.client_id)
assert.equals('config-secret', creds.client_secret)
end)
it('uses credentials file when config fields absent', function()
local cred_path = tmpdir .. '/creds.json'
oauth.save_json_file(cred_path, {
client_id = 'file-id',
client_secret = 'file-secret',
})
config.reset()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
sync = { gtasks = { credentials_path = cred_path } },
}
local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' })
local creds = c:resolve_credentials()
assert.equals('file-id', creds.client_id)
assert.equals('file-secret', creds.client_secret)
end)
it('unwraps installed wrapper format', function()
local cred_path = tmpdir .. '/wrapped.json'
oauth.save_json_file(cred_path, {
installed = {
client_id = 'wrapped-id',
client_secret = 'wrapped-secret',
},
})
config.reset()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
sync = { gcal = { credentials_path = cred_path } },
}
local c = oauth.new({ name = 'gcal', scope = 'x', port = 0, config_key = 'gcal' })
local creds = c:resolve_credentials()
assert.equals('wrapped-id', creds.client_id)
assert.equals('wrapped-secret', creds.client_secret)
end)
it('falls back to bundled credentials', function()
config.reset()
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
local orig_load = oauth.load_json_file
oauth.load_json_file = function()
return nil
end
local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' })
local creds = c:resolve_credentials()
oauth.load_json_file = orig_load
assert.equals(oauth._BUNDLED_CLIENT_ID, creds.client_id)
assert.equals(oauth._BUNDLED_CLIENT_SECRET, creds.client_secret)
end)
it('prefers config fields over credentials file', function()
local cred_path = tmpdir .. '/creds2.json'
oauth.save_json_file(cred_path, {
client_id = 'file-id',
client_secret = 'file-secret',
})
config.reset()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
sync = {
gtasks = {
credentials_path = cred_path,
client_id = 'config-id',
client_secret = 'config-secret',
},
},
}
local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' })
local creds = c:resolve_credentials()
assert.equals('config-id', creds.client_id)
assert.equals('config-secret', creds.client_secret)
end)
end)
describe('token_path', function()
it('includes backend name', function()
local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' })
assert.truthy(c:token_path():match('gtasks_tokens%.json$'))
end)
it('differs between backends', function()
local g = oauth.new({ name = 'gcal', scope = 'x', port = 0, config_key = 'gcal' })
local t = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' })
assert.not_equals(g:token_path(), t:token_path())
end)
end)
describe('load_tokens / save_tokens', function()
it('round-trips tokens', function()
local c = oauth.new({ name = 'test', scope = 'x', port = 0, config_key = 'gtasks' })
local path = c:token_path()
local dir = vim.fn.fnamemodify(path, ':h')
vim.fn.mkdir(dir, 'p')
local tokens = {
access_token = 'at',
refresh_token = 'rt',
expires_in = 3600,
obtained_at = 1000,
}
c:save_tokens(tokens)
local loaded = c:load_tokens()
assert.equals('at', loaded.access_token)
assert.equals('rt', loaded.refresh_token)
vim.fn.delete(dir, 'rf')
end)
end)
describe('auth_headers', function()
it('includes bearer token', function()
local headers = oauth.auth_headers('mytoken')
assert.equals('Authorization: Bearer mytoken', headers[1])
assert.equals('Content-Type: application/json', headers[2])
end)
end)
describe('new', function()
it('creates client with correct fields', function()
local c = oauth.new({
name = 'test',
scope = 'https://example.com',
port = 12345,
config_key = 'test',
})
assert.equals('test', c.name)
assert.equals('https://example.com', c.scope)
assert.equals(12345, c.port)
assert.equals('test', c.config_key)
end)
end)
end)

View file

@ -154,6 +154,240 @@ 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()
@ -181,4 +415,73 @@ describe('parse', function()
assert.are.equal('2026-03-15', meta.due) assert.are.equal('2026-03-15', meta.due)
end) end)
end) end)
describe('input_date_formats', function()
before_each(function()
config.reset()
end)
after_each(function()
vim.g.pending = nil
config.reset()
end)
it('parses MM/DD/YYYY format', function()
vim.g.pending = { input_date_formats = { '%m/%d/%Y' } }
config.reset()
local result = parse.resolve_date('03/15/2026')
assert.are.equal('2026-03-15', result)
end)
it('parses DD-Mon-YYYY format', function()
vim.g.pending = { input_date_formats = { '%d-%b-%Y' } }
config.reset()
local result = parse.resolve_date('15-Mar-2026')
assert.are.equal('2026-03-15', result)
end)
it('parses month name case-insensitively', function()
vim.g.pending = { input_date_formats = { '%d-%b-%Y' } }
config.reset()
local result = parse.resolve_date('15-MARCH-2026')
assert.are.equal('2026-03-15', result)
end)
it('parses two-digit year', function()
vim.g.pending = { input_date_formats = { '%m/%d/%y' } }
config.reset()
local result = parse.resolve_date('03/15/26')
assert.are.equal('2026-03-15', result)
end)
it('infers year when format has no year field', function()
vim.g.pending = { input_date_formats = { '%m/%d' } }
config.reset()
local result = parse.resolve_date('12/31')
assert.is_not_nil(result)
assert.truthy(result:match('^%d%d%d%d%-12%-31$'))
end)
it('returns nil for non-matching input', function()
vim.g.pending = { input_date_formats = { '%m/%d/%Y' } }
config.reset()
local result = parse.resolve_date('not-a-date')
assert.is_nil(result)
end)
it('tries formats in order, returns first match', function()
vim.g.pending = { input_date_formats = { '%d/%m/%Y', '%m/%d/%Y' } }
config.reset()
local result = parse.resolve_date('01/03/2026')
assert.are.equal('2026-03-01', result)
end)
it('works with body() for inline due token', function()
vim.g.pending = { input_date_formats = { '%m/%d/%Y' } }
config.reset()
local desc, meta = parse.body('Pay rent due:03/15/2026')
assert.are.equal('Pay rent', desc)
assert.are.equal('2026-03-15', meta.due)
end)
end)
end) end)

223
spec/recur_spec.lua Normal file
View file

@ -0,0 +1,223 @@
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)

260
spec/status_spec.lua Normal file
View file

@ -0,0 +1,260 @@
require('spec.helpers')
local config = require('pending.config')
local parse = require('pending.parse')
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()
package.loaded['pending'] = nil
pending = require('pending')
pending.store():load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
package.loaded['pending'] = nil
end)
describe('counts', function()
it('returns zeroes for empty store', function()
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()
local s = pending.store()
s:add({ description = 'One' })
s:add({ description = 'Two' })
s:save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(2, c.pending)
end)
it('counts priority tasks', function()
local s = pending.store()
s:add({ description = 'Urgent', priority = 1 })
s:add({ description = 'Normal' })
s:save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.priority)
end)
it('counts overdue tasks with date-only', function()
local s = pending.store()
s:add({ description = 'Old task', due = '2020-01-01' })
s:save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.overdue)
end)
it('counts overdue tasks with datetime', function()
local s = pending.store()
s:add({ description = 'Old task', due = '2020-01-01T08:00' })
s:save()
pending._recompute_counts()
local c = pending.counts()
assert.are.equal(1, c.overdue)
end)
it('counts today tasks', function()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
s:add({ description = 'Today task', due = today })
s: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()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
s:add({ description = 'Overdue', due = '2020-01-01' })
s:add({ description = 'Today', due = today })
s: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()
local s = pending.store()
local t = s:add({ description = 'Done', due = '2020-01-01' })
s:update(t.id, { status = 'done' })
s: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()
local s = pending.store()
local t = s:add({ description = 'Deleted', due = '2020-01-01' })
s:delete(t.id)
s: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()
local s = pending.store()
s:add({ description = 'Someday', due = '9999-12-30' })
s: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()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
s:add({ description = 'Soon', due = '2099-06-01' })
s:add({ description = 'Sooner', due = '2099-03-01' })
s:add({ description = 'Today', due = today })
s: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()
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()
local s = pending.store()
s:save()
pending._recompute_counts()
assert.are.equal('', pending.statusline())
end)
it('formats overdue only', function()
local s = pending.store()
s:add({ description = 'Old', due = '2020-01-01' })
s:save()
pending._recompute_counts()
assert.are.equal('1 overdue', pending.statusline())
end)
it('formats today only', function()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
s:add({ description = 'Today', due = today })
s:save()
pending._recompute_counts()
assert.are.equal('1 today', pending.statusline())
end)
it('formats overdue and today', function()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
s:add({ description = 'Old', due = '2020-01-01' })
s:add({ description = 'Today', due = today })
s: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()
local s = pending.store()
s:add({ description = 'Future', due = '2099-01-01' })
s:save()
pending._recompute_counts()
assert.is_false(pending.has_due())
end)
it('returns true when overdue', function()
local s = pending.store()
s:add({ description = 'Old', due = '2020-01-01' })
s:save()
pending._recompute_counts()
assert.is_true(pending.has_due())
end)
it('returns true when today', function()
local s = pending.store()
local today = os.date('%Y-%m-%d') --[[@as string]]
s:add({ description = 'Now', due = today })
s: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

@ -5,31 +5,30 @@ local store = require('pending.store')
describe('store', function() describe('store', function()
local tmpdir local tmpdir
local s
before_each(function() before_each(function()
tmpdir = vim.fn.tempname() tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p') vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' } s = store.new(tmpdir .. '/tasks.json')
config.reset() s:load()
store.unload()
end) end)
after_each(function() after_each(function()
vim.fn.delete(tmpdir, 'rf') vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset() config.reset()
end) end)
describe('load', function() describe('load', function()
it('returns empty data when no file exists', function() it('returns empty data when no file exists', function()
local data = store.load() local data = s:load()
assert.are.equal(1, data.version) assert.are.equal(1, data.version)
assert.are.equal(1, data.next_id) assert.are.equal(1, data.next_id)
assert.are.same({}, data.tasks) assert.are.same({}, data.tasks)
end) end)
it('loads existing data', function() it('loads existing data', function()
local path = config.get().data_path local path = tmpdir .. '/tasks.json'
local f = io.open(path, 'w') local f = io.open(path, 'w')
f:write(vim.json.encode({ f:write(vim.json.encode({
version = 1, version = 1,
@ -52,7 +51,7 @@ describe('store', function()
}, },
})) }))
f:close() f:close()
local data = store.load() local data = s:load()
assert.are.equal(3, data.next_id) assert.are.equal(3, data.next_id)
assert.are.equal(2, #data.tasks) assert.are.equal(2, #data.tasks)
assert.are.equal('Pending one', data.tasks[1].description) assert.are.equal('Pending one', data.tasks[1].description)
@ -60,7 +59,7 @@ describe('store', function()
end) end)
it('preserves unknown fields', function() it('preserves unknown fields', function()
local path = config.get().data_path local path = tmpdir .. '/tasks.json'
local f = io.open(path, 'w') local f = io.open(path, 'w')
f:write(vim.json.encode({ f:write(vim.json.encode({
version = 1, version = 1,
@ -77,8 +76,8 @@ describe('store', function()
}, },
})) }))
f:close() f:close()
store.load() s:load()
local task = store.get(1) local task = s:get(1)
assert.is_not_nil(task._extra) assert.is_not_nil(task._extra)
assert.are.equal('hello', task._extra.custom_field) assert.are.equal('hello', task._extra.custom_field)
end) end)
@ -86,9 +85,8 @@ describe('store', function()
describe('add', function() describe('add', function()
it('creates a task with incremented id', function() it('creates a task with incremented id', function()
store.load() local t1 = s:add({ description = 'First' })
local t1 = store.add({ description = 'First' }) local t2 = s:add({ description = 'Second' })
local t2 = store.add({ description = 'Second' })
assert.are.equal(1, t1.id) assert.are.equal(1, t1.id)
assert.are.equal(2, t2.id) assert.are.equal(2, t2.id)
assert.are.equal('pending', t1.status) assert.are.equal('pending', t1.status)
@ -96,60 +94,54 @@ describe('store', function()
end) end)
it('uses provided category', function() it('uses provided category', function()
store.load() local t = s:add({ description = 'Test', category = 'Work' })
local t = store.add({ description = 'Test', category = 'Work' })
assert.are.equal('Work', t.category) assert.are.equal('Work', t.category)
end) end)
end) end)
describe('update', function() describe('update', function()
it('updates fields and sets modified', function() it('updates fields and sets modified', function()
store.load() local t = s:add({ description = 'Original' })
local t = store.add({ description = 'Original' })
t.modified = '2025-01-01T00:00:00Z' t.modified = '2025-01-01T00:00:00Z'
store.update(t.id, { description = 'Updated' }) s:update(t.id, { description = 'Updated' })
local updated = store.get(t.id) local updated = s:get(t.id)
assert.are.equal('Updated', updated.description) assert.are.equal('Updated', updated.description)
assert.is_not.equal('2025-01-01T00:00:00Z', updated.modified) assert.is_not.equal('2025-01-01T00:00:00Z', updated.modified)
end) end)
it('sets end timestamp on completion', function() it('sets end timestamp on completion', function()
store.load() local t = s:add({ description = 'Test' })
local t = store.add({ description = 'Test' })
assert.is_nil(t['end']) assert.is_nil(t['end'])
store.update(t.id, { status = 'done' }) s:update(t.id, { status = 'done' })
local updated = store.get(t.id) local updated = s:get(t.id)
assert.is_not_nil(updated['end']) assert.is_not_nil(updated['end'])
end) end)
it('does not overwrite id or entry', function() it('does not overwrite id or entry', function()
store.load() local t = s:add({ description = 'Immutable fields' })
local t = store.add({ description = 'Immutable fields' })
local original_id = t.id local original_id = t.id
local original_entry = t.entry local original_entry = t.entry
store.update(t.id, { id = 999, entry = 'x' }) s:update(t.id, { id = 999, entry = 'x' })
local updated = store.get(original_id) local updated = s:get(original_id)
assert.are.equal(original_id, updated.id) assert.are.equal(original_id, updated.id)
assert.are.equal(original_entry, updated.entry) assert.are.equal(original_entry, updated.entry)
end) end)
it('does not overwrite end on second completion', function() it('does not overwrite end on second completion', function()
store.load() local t = s:add({ description = 'Complete twice' })
local t = store.add({ description = 'Complete twice' }) s:update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' })
store.update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' }) local first_end = s:get(t.id)['end']
local first_end = store.get(t.id)['end'] s:update(t.id, { status = 'done' })
store.update(t.id, { status = 'done' }) local task = s:get(t.id)
local task = store.get(t.id)
assert.are.equal(first_end, task['end']) assert.are.equal(first_end, task['end'])
end) end)
end) end)
describe('delete', function() describe('delete', function()
it('marks task as deleted', function() it('marks task as deleted', function()
store.load() local t = s:add({ description = 'To delete' })
local t = store.add({ description = 'To delete' }) s:delete(t.id)
store.delete(t.id) local deleted = s:get(t.id)
local deleted = store.get(t.id)
assert.are.equal('deleted', deleted.status) assert.are.equal('deleted', deleted.status)
assert.is_not_nil(deleted['end']) assert.is_not_nil(deleted['end'])
end) end)
@ -157,12 +149,10 @@ describe('store', function()
describe('save and round-trip', function() describe('save and round-trip', function()
it('persists and reloads correctly', function() it('persists and reloads correctly', function()
store.load() s:add({ description = 'Persisted', category = 'Work', priority = 1 })
store.add({ description = 'Persisted', category = 'Work', priority = 1 }) s:save()
store.save() s:load()
store.unload() local tasks = s:active_tasks()
store.load()
local tasks = store.active_tasks()
assert.are.equal(1, #tasks) assert.are.equal(1, #tasks)
assert.are.equal('Persisted', tasks[1].description) assert.are.equal('Persisted', tasks[1].description)
assert.are.equal('Work', tasks[1].category) assert.are.equal('Work', tasks[1].category)
@ -170,7 +160,7 @@ describe('store', function()
end) end)
it('round-trips unknown fields', function() it('round-trips unknown fields', function()
local path = config.get().data_path local path = tmpdir .. '/tasks.json'
local f = io.open(path, 'w') local f = io.open(path, 'w')
f:write(vim.json.encode({ f:write(vim.json.encode({
version = 1, version = 1,
@ -187,22 +177,78 @@ describe('store', function()
}, },
})) }))
f:close() f:close()
store.load() s:load()
store.save() s:save()
store.unload() s:load()
store.load() local task = s:get(1)
local task = store.get(1)
assert.are.equal('abc123', task._extra._gcal_event_id) assert.are.equal('abc123', task._extra._gcal_event_id)
end) end)
end) end)
describe('recurrence fields', function()
it('persists recur and recur_mode through round-trip', function()
s:add({ description = 'Recurring', recur = 'weekly', recur_mode = 'scheduled' })
s:save()
s:load()
local task = s:get(1)
assert.are.equal('weekly', task.recur)
assert.are.equal('scheduled', task.recur_mode)
end)
it('persists recur without recur_mode', function()
s:add({ description = 'Simple recur', recur = 'daily' })
s:save()
s:load()
local task = s:get(1)
assert.are.equal('daily', task.recur)
assert.is_nil(task.recur_mode)
end)
it('omits recur fields when not set', function()
s:add({ description = 'No recur' })
s:save()
s:load()
local task = s:get(1)
assert.is_nil(task.recur)
assert.is_nil(task.recur_mode)
end)
end)
describe('folded_categories', function()
it('defaults to empty table when missing from JSON', function()
local path = tmpdir .. '/tasks.json'
local f = io.open(path, 'w')
f:write(vim.json.encode({
version = 1,
next_id = 1,
tasks = {},
}))
f:close()
s:load()
assert.are.same({}, s:get_folded_categories())
end)
it('round-trips folded categories through save and load', function()
s:set_folded_categories({ 'Work', 'Home' })
s:save()
s:load()
assert.are.same({ 'Work', 'Home' }, s:get_folded_categories())
end)
it('persists empty list', function()
s:set_folded_categories({})
s:save()
s:load()
assert.are.same({}, s:get_folded_categories())
end)
end)
describe('active_tasks', function() describe('active_tasks', function()
it('excludes deleted tasks', function() it('excludes deleted tasks', function()
store.load() s:add({ description = 'Active' })
store.add({ description = 'Active' }) local t2 = s:add({ description = 'To delete' })
local t2 = store.add({ description = 'To delete' }) s:delete(t2.id)
store.delete(t2.id) local active = s:active_tasks()
local active = store.active_tasks()
assert.are.equal(1, #active) assert.are.equal(1, #active)
assert.are.equal('Active', active[1].description) assert.are.equal('Active', active[1].description)
end) end)
@ -210,27 +256,24 @@ describe('store', function()
describe('snapshot', function() describe('snapshot', function()
it('returns a table of tasks', function() it('returns a table of tasks', function()
store.load() s:add({ description = 'Snap one' })
store.add({ description = 'Snap one' }) s:add({ description = 'Snap two' })
store.add({ description = 'Snap two' }) local snap = s:snapshot()
local snap = store.snapshot()
assert.are.equal(2, #snap) assert.are.equal(2, #snap)
end) end)
it('returns a copy that does not affect the store', function() it('returns a copy that does not affect the store', function()
store.load() local t = s:add({ description = 'Original' })
local t = store.add({ description = 'Original' }) local snap = s:snapshot()
local snap = store.snapshot()
snap[1].description = 'Mutated' snap[1].description = 'Mutated'
local live = store.get(t.id) local live = s:get(t.id)
assert.are.equal('Original', live.description) assert.are.equal('Original', live.description)
end) end)
it('excludes deleted tasks', function() it('excludes deleted tasks', function()
store.load() local t = s:add({ description = 'Will be deleted' })
local t = store.add({ description = 'Will be deleted' }) s:delete(t.id)
store.delete(t.id) local snap = s:snapshot()
local snap = store.snapshot()
assert.are.equal(0, #snap) assert.are.equal(0, #snap)
end) end)
end) end)

114
spec/sync_spec.lua Normal file
View file

@ -0,0 +1,114 @@
require('spec.helpers')
local config = require('pending.config')
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()
package.loaded['pending'] = nil
pending = require('pending')
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
package.loaded['pending'] = nil
end)
describe('dispatch', function()
it('errors on unknown subcommand', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.command('notreal')
vim.notify = orig
assert.are.equal('[pending.nvim]: Unknown subcommand: 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.command('gcal notreal')
vim.notify = orig
assert.are.equal("[pending.nvim]: gcal: No 'notreal' action.", msg)
end)
it('lists actions when action is omitted', function()
local msg = nil
local orig = vim.notify
vim.notify = function(m)
msg = m
end
pending.command('gcal')
vim.notify = orig
assert.is_not_nil(msg)
assert.is_truthy(msg:find('push'))
end)
it('routes explicit push action', function()
local called = false
local gcal = require('pending.sync.gcal')
local orig_push = gcal.push
gcal.push = function()
called = true
end
pending.command('gcal push')
gcal.push = orig_push
assert.is_true(called)
end)
it('routes auth command', function()
local called = false
local orig_auth = pending.auth
pending.auth = function()
called = true
end
pending.command('auth')
pending.auth = orig_auth
assert.is_true(called)
end)
end)
it('works with sync.gcal config', function()
config.reset()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
sync = { gcal = { client_id = 'test-id' } },
}
local cfg = config.get()
assert.are.equal('test-id', cfg.sync.gcal.client_id)
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 push function', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('function', type(gcal.push))
end)
it('has health function', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('function', type(gcal.health))
end)
end)
end)

194
spec/textobj_spec.lua Normal file
View file

@ -0,0 +1,194 @@
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

@ -5,39 +5,38 @@ local store = require('pending.store')
describe('views', function() describe('views', function()
local tmpdir local tmpdir
local s
local views = require('pending.views') local views = require('pending.views')
before_each(function() before_each(function()
tmpdir = vim.fn.tempname() tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p') vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset() config.reset()
store.unload() s = store.new(tmpdir .. '/tasks.json')
store.load() s:load()
end) end)
after_each(function() after_each(function()
vim.fn.delete(tmpdir, 'rf') vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset() config.reset()
end) end)
describe('category_view', function() describe('category_view', function()
it('groups tasks under their category header', function() it('groups tasks under their category header', function()
store.add({ description = 'Task A', category = 'Work' }) s:add({ description = 'Task A', category = 'Work' })
store.add({ description = 'Task B', category = 'Work' }) s:add({ description = 'Task B', category = 'Work' })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(s:active_tasks())
assert.are.equal('## Work', lines[1]) assert.are.equal('# Work', lines[1])
assert.are.equal('header', meta[1].type) assert.are.equal('header', meta[1].type)
assert.is_true(lines[2]:find('Task A') ~= nil) assert.is_true(lines[2]:find('Task A') ~= nil)
assert.is_true(lines[3]:find('Task B') ~= nil) assert.is_true(lines[3]:find('Task B') ~= nil)
end) end)
it('places pending tasks before done tasks within a category', function() it('places pending tasks before done tasks within a category', function()
local t1 = store.add({ description = 'Done task', category = 'Work' }) local t1 = s:add({ description = 'Done task', category = 'Work' })
store.add({ description = 'Pending task', category = 'Work' }) s:add({ description = 'Pending task', category = 'Work' })
store.update(t1.id, { status = 'done' }) s:update(t1.id, { status = 'done' })
local _, meta = views.category_view(store.active_tasks()) local _, meta = views.category_view(s:active_tasks())
local pending_row, done_row local pending_row, done_row
for i, m in ipairs(meta) do for i, m in ipairs(meta) do
if m.type == 'task' and m.status == 'pending' then if m.type == 'task' and m.status == 'pending' then
@ -50,9 +49,9 @@ describe('views', function()
end) end)
it('sorts high-priority tasks before normal tasks within pending group', function() it('sorts high-priority tasks before normal tasks within pending group', function()
store.add({ description = 'Normal', category = 'Work', priority = 0 }) s:add({ description = 'Normal', category = 'Work', priority = 0 })
store.add({ description = 'High', category = 'Work', priority = 1 }) s:add({ description = 'High', category = 'Work', priority = 1 })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(s:active_tasks())
local high_row, normal_row local high_row, normal_row
for i, m in ipairs(meta) do for i, m in ipairs(meta) do
if m.type == 'task' then if m.type == 'task' then
@ -68,11 +67,11 @@ describe('views', function()
end) end)
it('sorts high-priority tasks before normal tasks within done group', function() it('sorts high-priority tasks before normal tasks within done group', function()
local t1 = store.add({ description = 'Done Normal', category = 'Work', priority = 0 }) local t1 = s:add({ description = 'Done Normal', category = 'Work', priority = 0 })
local t2 = store.add({ description = 'Done High', category = 'Work', priority = 1 }) local t2 = s:add({ description = 'Done High', category = 'Work', priority = 1 })
store.update(t1.id, { status = 'done' }) s:update(t1.id, { status = 'done' })
store.update(t2.id, { status = 'done' }) s:update(t2.id, { status = 'done' })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(s:active_tasks())
local high_row, normal_row local high_row, normal_row
for i, m in ipairs(meta) do for i, m in ipairs(meta) do
if m.type == 'task' then if m.type == 'task' then
@ -88,9 +87,9 @@ describe('views', function()
end) end)
it('gives each category its own header with blank lines between them', function() it('gives each category its own header with blank lines between them', function()
store.add({ description = 'Task A', category = 'Work' }) s:add({ description = 'Task A', category = 'Work' })
store.add({ description = 'Task B', category = 'Personal' }) s:add({ description = 'Task B', category = 'Personal' })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(s:active_tasks())
local headers = {} local headers = {}
local blank_found = false local blank_found = false
for i, m in ipairs(meta) do for i, m in ipairs(meta) do
@ -105,8 +104,8 @@ describe('views', function()
end) end)
it('formats task lines as /ID/ description', function() it('formats task lines as /ID/ description', function()
store.add({ description = 'My task', category = 'Inbox' }) s:add({ description = 'My task', category = 'Inbox' })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(s:active_tasks())
local task_line local task_line
for i, m in ipairs(meta) do for i, m in ipairs(meta) do
if m.type == 'task' then if m.type == 'task' then
@ -117,8 +116,8 @@ describe('views', function()
end) end)
it('formats priority task lines as /ID/- [!] description', function() it('formats priority task lines as /ID/- [!] description', function()
store.add({ description = 'Important', category = 'Inbox', priority = 1 }) s:add({ description = 'Important', category = 'Inbox', priority = 1 })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(s:active_tasks())
local task_line local task_line
for i, m in ipairs(meta) do for i, m in ipairs(meta) do
if m.type == 'task' then if m.type == 'task' then
@ -129,15 +128,15 @@ describe('views', function()
end) end)
it('sets LineMeta type=header for header lines with correct category', function() it('sets LineMeta type=header for header lines with correct category', function()
store.add({ description = 'T', category = 'School' }) s:add({ description = 'T', category = 'School' })
local _, meta = views.category_view(store.active_tasks()) local _, meta = views.category_view(s:active_tasks())
assert.are.equal('header', meta[1].type) assert.are.equal('header', meta[1].type)
assert.are.equal('School', meta[1].category) assert.are.equal('School', meta[1].category)
end) end)
it('sets LineMeta type=task with correct id and status', function() it('sets LineMeta type=task with correct id and status', function()
local t = store.add({ description = 'Do something', category = 'Inbox' }) local t = s:add({ description = 'Do something', category = 'Inbox' })
local _, meta = views.category_view(store.active_tasks()) local _, meta = views.category_view(s:active_tasks())
local task_meta local task_meta
for _, m in ipairs(meta) do for _, m in ipairs(meta) do
if m.type == 'task' then if m.type == 'task' then
@ -150,9 +149,9 @@ describe('views', function()
end) end)
it('sets LineMeta type=blank for blank separator lines', function() it('sets LineMeta type=blank for blank separator lines', function()
store.add({ description = 'A', category = 'Work' }) s:add({ description = 'A', category = 'Work' })
store.add({ description = 'B', category = 'Home' }) s:add({ description = 'B', category = 'Home' })
local _, meta = views.category_view(store.active_tasks()) local _, meta = views.category_view(s:active_tasks())
local blank_meta local blank_meta
for _, m in ipairs(meta) do for _, m in ipairs(meta) do
if m.type == 'blank' then if m.type == 'blank' then
@ -166,8 +165,8 @@ describe('views', function()
it('marks overdue pending tasks with meta.overdue=true', function() it('marks overdue pending tasks with meta.overdue=true', function()
local yesterday = os.date('%Y-%m-%d', os.time() - 86400) local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
local t = store.add({ description = 'Overdue task', category = 'Inbox', due = yesterday }) local t = s:add({ description = 'Overdue task', category = 'Inbox', due = yesterday })
local _, meta = views.category_view(store.active_tasks()) local _, meta = views.category_view(s:active_tasks())
local task_meta local task_meta
for _, m in ipairs(meta) do for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then if m.type == 'task' and m.id == t.id then
@ -179,8 +178,8 @@ describe('views', function()
it('does not mark future pending tasks as overdue', function() it('does not mark future pending tasks as overdue', function()
local tomorrow = os.date('%Y-%m-%d', os.time() + 86400) local tomorrow = os.date('%Y-%m-%d', os.time() + 86400)
local t = store.add({ description = 'Future task', category = 'Inbox', due = tomorrow }) local t = s:add({ description = 'Future task', category = 'Inbox', due = tomorrow })
local _, meta = views.category_view(store.active_tasks()) local _, meta = views.category_view(s:active_tasks())
local task_meta local task_meta
for _, m in ipairs(meta) do for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then if m.type == 'task' and m.id == t.id then
@ -192,9 +191,9 @@ describe('views', function()
it('does not mark done tasks with overdue due dates as overdue', function() it('does not mark done tasks with overdue due dates as overdue', function()
local yesterday = os.date('%Y-%m-%d', os.time() - 86400) local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
local t = store.add({ description = 'Done late', category = 'Inbox', due = yesterday }) local t = s:add({ description = 'Done late', category = 'Inbox', due = yesterday })
store.update(t.id, { status = 'done' }) s:update(t.id, { status = 'done' })
local _, meta = views.category_view(store.active_tasks()) local _, meta = views.category_view(s:active_tasks())
local task_meta local task_meta
for _, m in ipairs(meta) do for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then if m.type == 'task' and m.id == t.id then
@ -204,12 +203,36 @@ 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()
s:add({ description = 'Recurring', category = 'Inbox', recur = 'weekly' })
local _, meta = views.category_view(s: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()
s:add({ description = 'Normal', category = 'Inbox' })
local _, meta = views.category_view(s: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()
store.add({ description = 'Inbox task', category = 'Inbox' }) s:add({ description = 'Inbox task', category = 'Inbox' })
store.add({ description = 'Work task', category = 'Work' }) s:add({ description = 'Work task', category = 'Work' })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(s:active_tasks())
local first_header, second_header local first_header, second_header
for i, m in ipairs(meta) do for i, m in ipairs(meta) do
if m.type == 'header' then if m.type == 'header' then
@ -220,47 +243,47 @@ describe('views', function()
end end
end end
end end
assert.are.equal('## Work', first_header) assert.are.equal('# Work', first_header)
assert.are.equal('## Inbox', second_header) assert.are.equal('# Inbox', second_header)
end) end)
it('appends categories not in category_order after ordered ones', function() it('appends categories not in category_order after ordered ones', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work' } } vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work' } }
config.reset() config.reset()
store.add({ description = 'Errand', category = 'Errands' }) s:add({ description = 'Errand', category = 'Errands' })
store.add({ description = 'Work task', category = 'Work' }) s:add({ description = 'Work task', category = 'Work' })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(s:active_tasks())
local headers = {} local headers = {}
for i, m in ipairs(meta) do for i, m in ipairs(meta) do
if m.type == 'header' then if m.type == 'header' then
table.insert(headers, lines[i]) table.insert(headers, lines[i])
end end
end end
assert.are.equal('## Work', headers[1]) assert.are.equal('# Work', headers[1])
assert.are.equal('## Errands', headers[2]) assert.are.equal('# Errands', headers[2])
end) end)
it('preserves insertion order when category_order is empty', function() it('preserves insertion order when category_order is empty', function()
store.add({ description = 'Alpha task', category = 'Alpha' }) s:add({ description = 'Alpha task', category = 'Alpha' })
store.add({ description = 'Beta task', category = 'Beta' }) s:add({ description = 'Beta task', category = 'Beta' })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(s:active_tasks())
local headers = {} local headers = {}
for i, m in ipairs(meta) do for i, m in ipairs(meta) do
if m.type == 'header' then if m.type == 'header' then
table.insert(headers, lines[i]) table.insert(headers, lines[i])
end end
end end
assert.are.equal('## Alpha', headers[1]) assert.are.equal('# Alpha', headers[1])
assert.are.equal('## Beta', headers[2]) assert.are.equal('# Beta', headers[2])
end) end)
end) end)
describe('priority_view', function() describe('priority_view', function()
it('places all pending tasks before done tasks', function() it('places all pending tasks before done tasks', function()
local t1 = store.add({ description = 'Done A', category = 'Work' }) local t1 = s:add({ description = 'Done A', category = 'Work' })
store.add({ description = 'Pending B', category = 'Work' }) s:add({ description = 'Pending B', category = 'Work' })
store.update(t1.id, { status = 'done' }) s:update(t1.id, { status = 'done' })
local _, meta = views.priority_view(store.active_tasks()) local _, meta = views.priority_view(s:active_tasks())
local last_pending_row, first_done_row local last_pending_row, first_done_row
for i, m in ipairs(meta) do for i, m in ipairs(meta) do
if m.type == 'task' then if m.type == 'task' then
@ -275,9 +298,9 @@ describe('views', function()
end) end)
it('sorts pending tasks by priority desc within pending group', function() it('sorts pending tasks by priority desc within pending group', function()
store.add({ description = 'Low', category = 'Work', priority = 0 }) s:add({ description = 'Low', category = 'Work', priority = 0 })
store.add({ description = 'High', category = 'Work', priority = 1 }) s:add({ description = 'High', category = 'Work', priority = 1 })
local lines, meta = views.priority_view(store.active_tasks()) local lines, meta = views.priority_view(s:active_tasks())
local high_row, low_row local high_row, low_row
for i, m in ipairs(meta) do for i, m in ipairs(meta) do
if m.type == 'task' then if m.type == 'task' then
@ -292,9 +315,9 @@ describe('views', function()
end) end)
it('sorts pending tasks with due dates before those without', function() it('sorts pending tasks with due dates before those without', function()
store.add({ description = 'No due', category = 'Work' }) s:add({ description = 'No due', category = 'Work' })
store.add({ description = 'Has due', category = 'Work', due = '2099-12-31' }) s:add({ description = 'Has due', category = 'Work', due = '2099-12-31' })
local lines, meta = views.priority_view(store.active_tasks()) local lines, meta = views.priority_view(s:active_tasks())
local due_row, nodue_row local due_row, nodue_row
for i, m in ipairs(meta) do for i, m in ipairs(meta) do
if m.type == 'task' then if m.type == 'task' then
@ -309,9 +332,9 @@ describe('views', function()
end) end)
it('sorts pending tasks with earlier due dates before later due dates', function() it('sorts pending tasks with earlier due dates before later due dates', function()
store.add({ description = 'Later', category = 'Work', due = '2099-12-31' }) s:add({ description = 'Later', category = 'Work', due = '2099-12-31' })
store.add({ description = 'Earlier', category = 'Work', due = '2050-01-01' }) s:add({ description = 'Earlier', category = 'Work', due = '2050-01-01' })
local lines, meta = views.priority_view(store.active_tasks()) local lines, meta = views.priority_view(s:active_tasks())
local earlier_row, later_row local earlier_row, later_row
for i, m in ipairs(meta) do for i, m in ipairs(meta) do
if m.type == 'task' then if m.type == 'task' then
@ -326,15 +349,15 @@ describe('views', function()
end) end)
it('formats task lines as /ID/- [ ] description', function() it('formats task lines as /ID/- [ ] description', function()
store.add({ description = 'My task', category = 'Inbox' }) s:add({ description = 'My task', category = 'Inbox' })
local lines, _ = views.priority_view(store.active_tasks()) local lines, _ = views.priority_view(s:active_tasks())
assert.are.equal('/1/- [ ] My task', lines[1]) assert.are.equal('/1/- [ ] My task', lines[1])
end) end)
it('sets show_category=true for all task meta entries', function() it('sets show_category=true for all task meta entries', function()
store.add({ description = 'T1', category = 'Work' }) s:add({ description = 'T1', category = 'Work' })
store.add({ description = 'T2', category = 'Personal' }) s:add({ description = 'T2', category = 'Personal' })
local _, meta = views.priority_view(store.active_tasks()) local _, meta = views.priority_view(s:active_tasks())
for _, m in ipairs(meta) do for _, m in ipairs(meta) do
if m.type == 'task' then if m.type == 'task' then
assert.is_true(m.show_category == true) assert.is_true(m.show_category == true)
@ -343,9 +366,9 @@ describe('views', function()
end) end)
it('sets meta.category correctly for each task', function() it('sets meta.category correctly for each task', function()
store.add({ description = 'Work task', category = 'Work' }) s:add({ description = 'Work task', category = 'Work' })
store.add({ description = 'Home task', category = 'Home' }) s:add({ description = 'Home task', category = 'Home' })
local lines, meta = views.priority_view(store.active_tasks()) local lines, meta = views.priority_view(s:active_tasks())
local categories = {} local categories = {}
for i, m in ipairs(meta) do for i, m in ipairs(meta) do
if m.type == 'task' then if m.type == 'task' then
@ -362,8 +385,8 @@ describe('views', function()
it('marks overdue pending tasks with meta.overdue=true', function() it('marks overdue pending tasks with meta.overdue=true', function()
local yesterday = os.date('%Y-%m-%d', os.time() - 86400) local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
local t = store.add({ description = 'Overdue', category = 'Inbox', due = yesterday }) local t = s:add({ description = 'Overdue', category = 'Inbox', due = yesterday })
local _, meta = views.priority_view(store.active_tasks()) local _, meta = views.priority_view(s:active_tasks())
local task_meta local task_meta
for _, m in ipairs(meta) do for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then if m.type == 'task' and m.id == t.id then
@ -375,8 +398,8 @@ describe('views', function()
it('does not mark future pending tasks as overdue', function() it('does not mark future pending tasks as overdue', function()
local tomorrow = os.date('%Y-%m-%d', os.time() + 86400) local tomorrow = os.date('%Y-%m-%d', os.time() + 86400)
local t = store.add({ description = 'Future', category = 'Inbox', due = tomorrow }) local t = s:add({ description = 'Future', category = 'Inbox', due = tomorrow })
local _, meta = views.priority_view(store.active_tasks()) local _, meta = views.priority_view(s:active_tasks())
local task_meta local task_meta
for _, m in ipairs(meta) do for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then if m.type == 'task' and m.id == t.id then
@ -388,9 +411,9 @@ describe('views', function()
it('does not mark done tasks with overdue due dates as overdue', function() it('does not mark done tasks with overdue due dates as overdue', function()
local yesterday = os.date('%Y-%m-%d', os.time() - 86400) local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
local t = store.add({ description = 'Done late', category = 'Inbox', due = yesterday }) local t = s:add({ description = 'Done late', category = 'Inbox', due = yesterday })
store.update(t.id, { status = 'done' }) s:update(t.id, { status = 'done' })
local _, meta = views.priority_view(store.active_tasks()) local _, meta = views.priority_view(s:active_tasks())
local task_meta local task_meta
for _, m in ipairs(meta) do for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then if m.type == 'task' and m.id == t.id then
@ -399,5 +422,29 @@ 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()
s:add({ description = 'Recurring', category = 'Inbox', recur = 'daily' })
local _, meta = views.priority_view(s: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()
s:add({ description = 'Normal', category = 'Inbox' })
local _, meta = views.priority_view(s: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)