diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yaml b/.github/DISCUSSION_TEMPLATE/q-a.yaml index a65fd46..0e657eb 100644 --- a/.github/DISCUSSION_TEMPLATE/q-a.yaml +++ b/.github/DISCUSSION_TEMPLATE/q-a.yaml @@ -1,4 +1,4 @@ -title: 'Q&A' +title: "Q&A" labels: [] body: - type: markdown diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index baae06b..0796c39 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -1,14 +1,13 @@ name: Bug Report description: Report a bug -title: 'bug: ' +title: "bug: " labels: [bug] body: - type: checkboxes attributes: label: Prerequisites options: - - label: - I have searched [existing + - label: I have searched [existing issues](https://github.com/barrettruth/pending.nvim/issues) required: true - label: I have updated to the latest version @@ -16,16 +15,16 @@ body: - type: textarea attributes: - label: 'Neovim version' - description: 'Output of `nvim --version`' + label: "Neovim version" + description: "Output of `nvim --version`" render: text validations: required: true - type: input attributes: - label: 'Operating system' - placeholder: 'e.g. Arch Linux, macOS 15, Ubuntu 24.04' + label: "Operating system" + placeholder: "e.g. Arch Linux, macOS 15, Ubuntu 24.04" validations: required: true @@ -49,8 +48,8 @@ body: - type: textarea attributes: - label: 'Health check' - description: 'Output of `:checkhealth task`' + label: "Health check" + description: "Output of `:checkhealth task`" render: text - type: textarea diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index cabb27c..f4c02eb 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -1,14 +1,13 @@ name: Feature Request description: Suggest a feature -title: 'feat: ' +title: "feat: " labels: [enhancement] body: - type: checkboxes attributes: label: Prerequisites options: - - label: - I have searched [existing + - label: I have searched [existing issues](https://github.com/barrettruth/pending.nvim/issues) required: true diff --git a/.github/workflows/luarocks.yaml b/.github/workflows/luarocks.yaml index 9b6664e..9f934a5 100644 --- a/.github/workflows/luarocks.yaml +++ b/.github/workflows/luarocks.yaml @@ -3,7 +3,7 @@ name: luarocks on: push: tags: - - 'v*' + - "v*" jobs: quality: diff --git a/.gitignore b/.gitignore index 93ac2c5..7cdfb66 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ doc/tags *.log +minimal_init.lua .*cache* CLAUDE.md diff --git a/.luarc.json b/.luarc.json index 23646d3..c8eaaf9 100644 --- a/.luarc.json +++ b/.luarc.json @@ -2,7 +2,14 @@ "runtime.version": "LuaJIT", "runtime.path": ["lua/?.lua", "lua/?/init.lua"], "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.ignoreDir": [".direnv"], "completion.callSnippet": "Replace" } diff --git a/README.md b/README.md index 98e14d3..43c8447 100644 --- a/README.md +++ b/README.md @@ -2,145 +2,45 @@ Edit tasks like text. `:w` saves them. -A buffer-centric task manager for Neovim. Tasks live in a plain buffer — add -with `o`, delete with `dd`, reorder with `dd`/`p`, rename by editing. Write the -buffer and the diff is computed against a JSON store. No UI chrome, no floating -windows, no abstractions between you and your tasks. +![demo](assets/demo.gif) -## How it works +## Requirements -``` -School - ! Read chapter 5 Feb 28 - Submit homework Feb 25 +- Neovim 0.10+ +- (Optionally) `curl` for Google Calendar and Google Task sync -Errands - Buy groceries Mar 01 - Clean apartment -``` +## Installation -Category headers sit at column 0. Tasks are indented below them. `!` marks -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 ``` -**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 | -| `` | Toggle complete (immediate) | -| `` | 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 +## Documentation ```vim -:Pending add Buy groceries due:2026-03-15 -:Pending add School: Submit homework +:help pending.nvim ``` -### Archive +## Icons -```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: +All display characters are configurable. Defaults produce markdown-style checkboxes (`[ ]`, `[x]`, `[!]`): ```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', + icons = { + pending = ' ', done = 'x', priority = '!', + due = '.', recur = '~', category = '#', }, } ``` -```vim -:Pending sync -``` +See `:help pending.Icons` for nerd font examples. -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. +## Acknowledgements -## Mappings - -The plugin defines `` mappings for custom keybinds: - -```lua -vim.keymap.set('n', 't', '(pending-open)') -vim.keymap.set('n', 'T', '(pending-toggle)') -``` - -| Plug mapping | Action | -| -------------------------- | -------------------- | -| `(pending-open)` | Open task buffer | -| `(pending-toggle)` | Toggle complete | -| `(pending-view)` | Switch view | -| `(pending-priority)` | Toggle priority flag | -| `(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 - -```vim -:checkhealth pending -``` +- [dooing](https://github.com/atiladefreitas/dooing) +- [todo-comments.nvim](https://github.com/folke/todo-comments.nvim) +- [todotxt.nvim](https://github.com/arnarg/todotxt.nvim) diff --git a/doc/pending.txt b/doc/pending.txt index 4eb8e40..914644e 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -30,21 +30,50 @@ concealed tokens and are never visible during editing. Features: ~ - Oil-style buffer editing: standard Vim motions for all task operations -- Inline metadata syntax: `due:` and `cat:` tokens parsed on `:w` -- Relative date input: `today`, `tomorrow`, `+Nd`, weekday names -- Two views: category (default) and priority flat list -- Multi-level undo (up to 20 `:w` saves, session-only) +- Inline metadata syntax: `due:`, `cat:`, and `rec:` tokens parsed on `:w` +- Relative date input: `today`, `tomorrow`, `+Nd`, `+Nw`, `+Nm`, weekday + names, month names, ordinals, and more +- Recurring tasks with automatic next-date spawning on completion +- Two views: category (default) and queue (priority-sorted flat list) +- Multi-level undo (up to 20 `:w` saves, persisted across sessions) - Quick-add from the command line with `:Pending add` - Quickfix list of overdue/due-today tasks via `:Pending due` - Foldable category sections (`zc`/`zo`) in category view +- Omnifunc completion for `cat:`, `due:`, and `rec:` tokens (``) - Google Calendar one-way push via OAuth PKCE +- Google Tasks bidirectional sync via OAuth PKCE + +============================================================================== +CONTENTS *pending-contents* + + 1. Introduction ............................................. |pending.nvim| + 2. Requirements ..................................... |pending-requirements| + 3. Install ............................................... |pending-install| + 4. Usage ................................................... |pending-usage| + 5. Commands .............................................. |pending-commands| + 6. Mappings .............................................. |pending-mappings| + 7. Views ................................................... |pending-views| + 8. Filters ............................................... |pending-filters| + 9. Inline Metadata ....................................... |pending-metadata| + 10. Date Input .............................................. |pending-dates| + 11. Recurrence ......................................... |pending-recurrence| + 12. Configuration ........................................... |pending-config| + 13. Store Resolution .......................... |pending-store-resolution| + 14. Highlight Groups .................................... |pending-highlights| + 15. Lua API ................................................... |pending-api| + 16. Recipes ............................................... |pending-recipes| + 17. Sync Backends ................................... |pending-sync-backend| + 18. Google Calendar .......................................... |pending-gcal| + 19. Google Tasks ............................................ |pending-gtasks| + 20. Data Format .............................................. |pending-data| + 21. Health Check ........................................... |pending-health| ============================================================================== REQUIREMENTS *pending-requirements* - Neovim 0.10+ - No external dependencies for local use -- `curl` and `openssl` are required for Google Calendar sync +- `curl` is required for the `gcal` and `gtasks` sync backends ============================================================================== INSTALL *pending-install* @@ -86,39 +115,6 @@ persists across window switches; reopening with `:Pending` focuses the existing window if one is open. The buffer is automatically reloaded from disk when entered unmodified. -============================================================================== -INLINE METADATA *pending-metadata* - -Metadata tokens may be appended to any task line before saving. Tokens are -parsed from the right and consumed until a non-metadata token is reached. - -Supported tokens: ~ - - `due:YYYY-MM-DD` Set a due date using an absolute date. - `due:today` Resolve to today's date. - `due:tomorrow` Resolve to tomorrow's date. - `due:+Nd` Resolve to N days from today (e.g. `due:+3d`). - `due:mon` Resolve to the next occurrence of that weekday. - Supported: `sun` `mon` `tue` `wed` `thu` `fri` `sat` - `cat:Name` Move the task to the named category on save. - -The token name for due dates defaults to `due` and is configurable via -`date_syntax` in |pending-config|. If `date_syntax` is set to `by`, write -`by:2026-03-15` instead. - -Example: > - - Buy milk due:2026-03-15 cat:Errands -< - -On `:w`, the description becomes `Buy milk`, the due date is stored as -`2026-03-15` and rendered as right-aligned virtual text, and the task is -placed under the `Errands` category header. - -Parsing stops at the first token that is not a recognised metadata token. -Repeated tokens of the same type also stop parsing — only one `due:` and one -`cat:` per task line are consumed. - ============================================================================== COMMANDS *pending-commands* @@ -135,6 +131,7 @@ COMMANDS *pending-commands* :Pending add Buy groceries due:2026-03-15 :Pending add School: Submit homework :Pending add Errands: Pick up dry cleaning due:fri + :Pending add Work: standup due:tomorrow rec:weekdays < If the buffer is currently open it is re-rendered after the add. @@ -151,17 +148,103 @@ COMMANDS *pending-commands* Populate the quickfix list with all tasks that are overdue or due today. Open the list with |:copen| to navigate to each task's category. - *:Pending-sync* -:Pending sync - Push pending tasks that have a due date to Google Calendar as all-day - events. Requires |pending-gcal| to be configured. See |pending-gcal| for - full details on what gets created, updated, and deleted. + *:Pending-gtasks* +:Pending gtasks {action} + Run a Google Tasks action. An explicit action is required. + + Actions: ~ + `sync` Push local changes then pull remote changes. + `push` Push local changes to Google Tasks only. + `pull` Pull remote changes from Google Tasks only. + `auth` Run the OAuth authorization flow. + + Examples: >vim + :Pending gtasks sync " push then pull + :Pending gtasks push " push local → Google Tasks + :Pending gtasks pull " pull Google Tasks → local + :Pending gtasks auth " authorize +< + + Tab completion after `:Pending gtasks ` lists available actions. + See |pending-gtasks| for full details. + + *:Pending-gcal* +:Pending gcal {action} + Run a Google Calendar action. An explicit action is required. + + Actions: ~ + `push` Push tasks with due dates to Google Calendar. + `auth` Run the OAuth authorization flow. + + Examples: >vim + :Pending gcal push " push to Google Calendar + :Pending gcal auth " authorize +< + + Tab completion after `:Pending gcal ` lists available actions. + See |pending-gcal| for full details. + + *:Pending-filter* +:Pending filter {predicates} + Apply a filter to the task buffer. {predicates} is a space-separated list + of one or more predicate tokens. Only tasks matching all predicates (AND + semantics) are shown. Hidden tasks are not deleted — they are preserved in + the store and reappear when the filter is cleared. >vim + :Pending filter cat:Work + :Pending filter overdue + :Pending filter cat:Work overdue + :Pending filter priority + :Pending filter clear +< + When a filter is active the buffer's first line shows: > + FILTER: cat:Work overdue +< + The user can edit this line inline and `:w` to change the active filter. + Deleting the `FILTER:` line entirely and saving clears the filter. + `:Pending filter clear` also clears the filter programmatically. + + Tab completion after `:Pending filter ` lists available predicates and + category values. Already-used predicates are excluded from completions. + + See |pending-filters| for the full list of supported predicates. + + *:Pending-edit* +:Pending edit {id} [{operations}] + Edit metadata on an existing task without opening the buffer. {id} is the + numeric task ID. One or more operations follow: >vim + :Pending edit 5 due:tomorrow cat:Work +! + :Pending edit 5 -due -cat -rec + :Pending edit 5 rec:!weekly due:fri +< + Operations: ~ + `due:` Set due date (accepts all |pending-dates| vocabulary). + `cat:` Set category. + `rec:` Set recurrence (prefix `!` for completion-based). + `+!` Add priority flag. + `-!` Remove priority flag. + `-due` Clear due date. + `-cat` Clear category. + `-rec` Clear recurrence. + + Tab completion is available for IDs, field names, date values, categories, + and recurrence patterns. *:Pending-undo* :Pending undo Undo the last `:w` save, restoring the task store to its previous state. Equivalent to the `U` buffer-local key (see |pending-mappings|). Up to 20 - levels of undo are retained per session. + levels of undo are persisted across sessions. + + *:Pending-init* +:Pending init + Create a project-local `.pending.json` file in the current working + directory. After creation, `:Pending` will use this file instead of the + global store (see |pending-store-resolution|). Errors if `.pending.json` + already exists in the current directory. + + *:PendingTab* +:PendingTab + Open the task buffer in a new tab. ============================================================================== MAPPINGS *pending-mappings* @@ -169,27 +252,63 @@ MAPPINGS *pending-mappings* The following keys are set buffer-locally when the task buffer opens. They are active only in the `pending://` buffer. -Buffer-local keys: ~ +Buffer-local keys are configured via the `keymaps` table in |pending-config|. +The defaults are shown below. Set any key to `false` to disable it. + +Default buffer-local keys: ~ Key Action ~ ------- ------------------------------------------------ - `` Toggle complete / uncomplete the task at cursor - `!` Toggle the priority flag on the task at cursor - `D` Prompt for a due date on the task at cursor - `` Switch between category view and priority view - `U` Undo the last `:w` save - `g?` Show a help popup with available keys + `q` Close the task buffer (`close`) + `` Toggle complete / uncomplete (`toggle`) + `!` Toggle the priority flag (`priority`) + `D` Prompt for a due date (`date`) + `F` Prompt for filter predicates (`filter`) + `` Switch between category / queue view (`view`) + `U` Undo the last `:w` save (`undo`) + `o` Insert a new task line below (`open_line`) + `O` Insert a new task line above (`open_line_above`) `zc` Fold the current category section (category view only) `zo` Unfold the current category section (category view only) -`o` and `O` are overridden to insert a correctly-formatted blank task line -at the position below or above the cursor rather than using standard Vim -indentation. `dd`, `p`, `P`, and `:w` work as expected. +Text objects (operator-pending and visual): ~ + + Key Action ~ + ------- ------------------------------------------------ + `at` Select the current task line (`a_task`) + `it` Select the task description only (`i_task`) + `aC` Select a category: header + tasks + blanks (`a_category`) + `iC` Select inner category: tasks only (`i_category`) + +`at` supports count: `d3at` deletes three consecutive tasks. `it` selects +the description text between the checkbox prefix and trailing metadata +tokens (`due:`, `cat:`, `rec:`), making `cit` the natural way to retype a +task description without touching its metadata. + +`aC` and `iC` are no-ops in the queue view (no headers to delimit). + +Motions (normal, visual, operator-pending): ~ + + Key Action ~ + ------- ------------------------------------------------ + `]]` Jump to the next category header (`next_header`) + `[[` Jump to the previous category header (`prev_header`) + `]t` Jump to the next task line (`next_task`) + `[t` Jump to the previous task line (`prev_task`) + +All motions support count: `3]]` jumps three headers forward. `]]` and +`[[` are no-ops in the queue view. `]t` and `[t` work in both views. + +`dd`, `p`, `P`, and `:w` work as standard Vim operations. *(pending-open)* (pending-open) Open the task buffer. Maps to |:Pending| with no arguments. + *(pending-close)* +(pending-close) + Close the task buffer window. + *(pending-toggle)* (pending-toggle) Toggle complete / uncomplete for the task under the cursor. @@ -206,6 +325,57 @@ indentation. `dd`, `p`, `P`, and `:w` work as expected. (pending-view) Switch between category view and priority view. + *(pending-undo)* +(pending-undo) + Undo the last `:w` save. + + *(pending-filter)* +(pending-filter) + Prompt for filter predicates via |vim.ui.input|. + + *(pending-open-line)* +(pending-open-line) + Insert a correctly-formatted blank task line below the cursor. + + *(pending-open-line-above)* +(pending-open-line-above) + Insert a correctly-formatted blank task line above the cursor. + + *(pending-a-task)* +(pending-a-task) + Select the current task line (linewise). Supports count. + + *(pending-i-task)* +(pending-i-task) + Select the task description text (characterwise). + + *(pending-a-category)* +(pending-a-category) + Select a full category section: header, tasks, and surrounding blanks. + + *(pending-i-category)* +(pending-i-category) + Select tasks within a category, excluding the header and blanks. + + *(pending-next-header)* +(pending-next-header) + Jump to the next category header. Supports count. + + *(pending-prev-header)* +(pending-prev-header) + Jump to the previous category header. Supports count. + + *(pending-next-task)* +(pending-next-task) + Jump to the next task line, skipping headers and blanks. + + *(pending-prev-task)* +(pending-prev-task) + Jump to the previous task line, skipping headers and blanks. + +(pending-tab) *(pending-tab)* + Open the task buffer in a new tab. See |:PendingTab|. + Example configuration: >lua vim.keymap.set('n', 't', '(pending-open)') vim.keymap.set('n', 'T', '(pending-toggle)') @@ -224,12 +394,182 @@ Category view (default): ~ *pending-view-category* first within each group. Category sections are foldable with `zc` and `zo`. -Priority view: ~ *pending-view-priority* +Queue view: ~ *pending-view-queue* A flat list of all tasks sorted by priority, then by due date (tasks without a due date sort last), then by internal order. Done tasks appear after all pending tasks. Category names are shown as right-aligned virtual text alongside the due date virtual text so tasks remain identifiable - across categories. + across categories. The buffer is named `pending://queue`. + +============================================================================== +FILTERS *pending-filters* + +Filters narrow the task buffer to a subset of tasks without deleting any data. +Hidden tasks are preserved in the store and reappear when the filter is +cleared. Filter state is session-local — it does not persist across Neovim +restarts. + +Set a filter with |:Pending-filter|, the `F` buffer key, or by editing the +`FILTER:` line: >vim + :Pending filter cat:Work overdue +< + +Multiple predicates are separated by spaces and combined with AND logic — a +task must match every predicate to be shown. + +Available predicates: ~ + + `cat:X` Show only tasks whose category is exactly `X`. Tasks with no + category (assigned to `default_category`) are hidden unless + `default_category` matches `X`. + + `overdue` Show only pending tasks with a due date strictly before today. + + `today` Show only pending tasks with a due date equal to today. + + `priority` Show only tasks with priority > 0 (the `!` marker). + + `clear` Special value for |:Pending-filter| — clears the active filter + and shows all tasks. + +FILTER: line: ~ *pending-filter-line* + +When a filter is active, the first line of the task buffer is: > + FILTER: cat:Work overdue +< + +This line is editable. Write the buffer with `:w` to apply the updated +predicates. Deleting the `FILTER:` line and saving clears the filter. The +line is highlighted with |PendingFilter| and does not appear in the stored +task data. + +============================================================================== +INLINE METADATA *pending-metadata* + +Metadata tokens may be appended to any task line before saving. Tokens are +parsed from the right and consumed until a non-metadata token is reached. + +Supported tokens: ~ + + `due:YYYY-MM-DD` Set a due date using an absolute date. + `due:` Resolve a named date (see |pending-dates| below). + `cat:Name` Move the task to the named category on save. + `rec:` Set a recurrence rule (see |pending-recurrence|). + +The token name for due dates defaults to `due` and is configurable via +`date_syntax` in |pending-config|. The token name for recurrence defaults to +`rec` and is configurable via `recur_syntax`. + +Example: > + + Buy milk due:2026-03-15 cat:Errands + Take out trash due:monday rec:weekly +< + +On `:w`, the description becomes `Buy milk`, the due date is stored as +`2026-03-15` and rendered as right-aligned virtual text, and the task is +placed under the `Errands` category header. + +Parsing stops at the first token that is not a recognised metadata token. +Repeated tokens of the same type also stop parsing — only one `due:`, one +`cat:`, and one `rec:` per task line are consumed. + +Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types. +In insert mode, type the token prefix and press `` to see +suggestions. + +============================================================================== +DATE INPUT *pending-dates* + +Named dates can be used anywhere a date is accepted: the `due:` inline +token, the `D` prompt, and `:Pending add`. + + Token Resolves to ~ + ----- ----------- + `today` Today's date + `tomorrow` Tomorrow's date + `yesterday` Yesterday's date + `eod` Today (end of day semantics) + `+Nd` N days from today (e.g. `+3d`) + `+Nw` N weeks from today (e.g. `+2w`) + `+Nm` N months from today (e.g. `+1m`) + `-Nd` N days ago (e.g. `-2d`) + `-Nw` N weeks ago (e.g. `-1w`) + `mon`–`sun` Next occurrence of that weekday + `jan`–`dec` 1st of next occurrence of that month + `1st`–`31st` Next occurrence of that day-of-month + `sow` / `eow` Monday / Sunday of current week + `som` / `eom` First / last day of current month + `soq` / `eoq` First / last day of current quarter + `soy` / `eoy` January 1 / December 31 of current year + `later` / `someday` Sentinel date (default: `9999-12-30`) + +Time suffix: ~ *pending-dates-time* +Any named date or absolute date accepts an `@` time suffix. Supported +formats: `HH:MM` (24h), `H:MM`, bare hour (`9`, `14`), and am/pm +(`2pm`, `9:30am`, `12am`). All forms are normalized to `HH:MM` on save. > + + due:tomorrow@2pm " tomorrow at 14:00 + due:fri@9 " next Friday at 09:00 + due:+1w@17:00 " one week from today at 17:00 + due:tomorrow@9:30am " tomorrow at 09:30 + due:2026-03-15@08:00 " absolute date with time + due:2026-03-15T14:30 " ISO 8601 datetime (also accepted) +< + +Tasks with a time component are not considered overdue until after the +specified time. The time is displayed alongside the date in virtual text +and preserved across recurrence advances. + +============================================================================== +RECURRENCE *pending-recurrence* + +Tasks can recur on a schedule. Add a `rec:` token to set recurrence: > + + - [ ] Take out trash due:monday rec:weekly + - [ ] Pay rent due:2026-03-01 rec:monthly + - [ ] Standup due:tomorrow rec:weekdays +< + +When a recurring task is marked done with ``: +1. The current task stays as done (preserving history). +2. A new pending task is created with the same description, category, + priority, and recurrence — with the due date advanced to the next + occurrence. + +Shorthand patterns: ~ + + Pattern Meaning ~ + ------- ------- + `daily` Every day + `weekdays` Monday through Friday + `weekly` Every week + `biweekly` Every 2 weeks (alias: `2w`) + `monthly` Every month + `quarterly` Every 3 months (alias: `3m`) + `yearly` Every year (alias: `annual`) + `Nd` Every N days (e.g. `3d`) + `Nw` Every N weeks (e.g. `2w`) + `Nm` Every N months (e.g. `6m`) + `Ny` Every N years (e.g. `2y`) + +For patterns the shorthand cannot express, use a raw RRULE fragment: > + rec:FREQ=MONTHLY;BYDAY=1MO +< + +Completion-based recurrence: ~ *pending-recur-completion* +By default, recurrence is schedule-based: the next due date advances from the +original schedule, skipping to the next future occurrence. Prefix the pattern +with `!` for completion-based mode, where the next due date advances from the +completion date: > + rec:!weekly +< +Schedule-based is like org-mode `++`; completion-based is like `.+`. + +Google Calendar: ~ +Recurrence patterns map directly to iCalendar RRULE strings for future GCal +sync support. Completion-based recurrence cannot be synced (it is inherently +local). ============================================================================== CONFIGURATION *pending-config* @@ -239,13 +579,34 @@ loads: >lua vim.g.pending = { data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', default_view = 'category', - default_category = 'Inbox', + default_category = 'Todo', date_format = '%b %d', date_syntax = 'due', + recur_syntax = 'rec', + someday_date = '9999-12-30', category_order = {}, - gcal = { - calendar = 'Tasks', - credentials_path = '/path/to/client_secret.json', + keymaps = { + close = 'q', + toggle = '', + view = '', + 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 = { + gcal = {}, + gtasks = {}, }, } < @@ -255,15 +616,17 @@ All fields are optional. Unset fields use the defaults shown above. *pending.Config* Fields: ~ {data_path} (string) - Path to the JSON file where tasks are stored. + Path to the global JSON file where tasks are stored. Default: `stdpath('data') .. '/pending/tasks.json'`. The directory is created automatically on first save. + See |pending-store-resolution| for how the active + store is chosen at runtime. {default_view} ('category'|'priority', default: 'category') The view to use when the buffer is opened for the first time in a session. - {default_category} (string, default: 'Inbox') + {default_category} (string, default: 'Todo') Category assigned to new tasks when no `cat:` token is present and no `Category: ` prefix is used with `:Pending add`. @@ -278,70 +641,79 @@ Fields: ~ this to use a different keyword, for example `'by'` to write `by:2026-03-15` instead of `due:2026-03-15`. + {recur_syntax} (string, default: 'rec') + The token name for inline recurrence metadata. Change + this to use a different keyword, for example + `'repeat'` to write `repeat:weekly`. + + {someday_date} (string, default: '9999-12-30') + The date that `later` and `someday` resolve to. This + acts as a "no date" sentinel for GTD-style workflows. + {category_order} (string[], default: {}) Ordered list of category names. In category view, categories that appear in this list are shown in the given order. Categories not in the list are appended after the ordered ones in their natural order. - {gcal} (table, default: nil) - Google Calendar sync configuration. See - |pending.GcalConfig|. Omit this field entirely to - disable Google Calendar sync. + {keymaps} (table, default: see below) *pending.Keymaps* + Buffer-local key bindings. Each field maps an action + name to a key string. Set a field to `false` to + disable that binding. Unset fields use the default. + See |pending-mappings| for the full list of actions + and their default keys. -============================================================================== -GOOGLE CALENDAR *pending-gcal* - -pending.nvim can push tasks with due dates to a dedicated Google Calendar as -all-day events. This is a one-way push; changes made in Google Calendar are -not pulled back into pending.nvim. - -Configuration: >lua - vim.g.pending = { - gcal = { - calendar = 'Tasks', - credentials_path = '/path/to/client_secret.json', - }, - } + {debug} (boolean, default: false) + Enable diagnostic logging. When `true`, textobj + motions, mapping registration, and cursor jumps + emit messages at `vim.log.levels.DEBUG`. Use + |:messages| to inspect the output. Useful for + diagnosing keymap conflicts (e.g. `]t` colliding + with Neovim defaults) or motion misbehavior. + Example: >lua + vim.g.pending = { debug = true } < - *pending.GcalConfig* -Fields: ~ - {calendar} (string, default: 'Pendings') - Name of the Google Calendar to sync to. If a calendar - with this name does not exist it is created - automatically on the first sync. + {sync} (table, default: {}) *pending.SyncConfig* + Sync backend configuration. Each key is a backend + name and the value is the backend-specific config + table. Built-in backends: `gcal`, `gtasks`. Both + ship bundled OAuth credentials so no setup is + needed beyond `:Pending auth`. - {credentials_path} (string) - Path to the OAuth client secret JSON file downloaded - from the Google Cloud Console. Default: - `stdpath('data')..'/pending/gcal_credentials.json'`. - The file may be in the `installed` wrapper format - that Google provides or as a bare credentials object. + {icons} (table) *pending.Icons* + Icon characters displayed in the buffer. The + {pending}, {done}, and {priority} characters + appear inside brackets (`[icon]`) as an overlay + on the checkbox. The {category} character + prefixes both header lines and EOL category + labels. Fields: + {pending} Pending task character. Default: ' ' + {done} Done task character. Default: 'x' + {priority} Priority task character. Default: '!' + {due} Due date prefix. Default: '.' + {recur} Recurrence prefix. Default: '~' + {category} Category prefix. Default: '#' -OAuth flow: ~ -On the first `:Pending sync` call the plugin detects that no refresh token -exists and opens the Google authorization URL in the browser using -|vim.ui.open()|. A temporary local HTTP server listens on port 18392 for the -OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used — -`openssl` generates the code challenge. After the user grants consent, the -authorization code is exchanged for tokens and the refresh token is stored at -`stdpath('data')/pending/gcal_tokens.json` with mode `600`. Subsequent syncs -use the stored refresh token and refresh the access token automatically when -it is about to expire. +============================================================================== +STORE RESOLUTION *pending-store-resolution* -`:Pending sync` behavior: ~ -For each task in the store: -- A pending task with a due date and no existing event: a new all-day event is - created and the event ID is stored in the task's `_extra` table. -- A pending task with a due date and an existing event: the event summary and - date are updated in place. -- A done or deleted task with an existing event: the event is deleted. -- A pending task with no due date that had an existing event: the event is - deleted. +When pending.nvim opens the task buffer it resolves which store file to use: -A summary notification is shown after sync: `created: N, updated: N, -deleted: N`. +1. Search upward from `vim.fn.getcwd()` for a file named `.pending.json`. +2. If found, use that file as the active store (project-local store). +3. If not found, fall back to `data_path` from |pending-config| (global + store). + +This means placing a `.pending.json` file in a project root makes that +project use an isolated task list. Tasks in the project store are completely +separate from tasks in the global store; there is no aggregation. + +To create a project-local store in the current directory: >vim + :Pending init +< + +The `:checkhealth pending` report shows which store file is currently active. ============================================================================== HIGHLIGHT GROUPS *pending-highlights* @@ -369,6 +741,16 @@ PendingDone Applied to the text of completed tasks. *PendingPriority* PendingPriority Applied to the `! ` priority marker on priority tasks. + Default: links to `DiagnosticWarn`. + + *PendingRecur* +PendingRecur Applied to the recurrence indicator virtual text shown + alongside due dates for recurring tasks. + Default: links to `DiagnosticInfo`. + + *PendingFilter* +PendingFilter Applied to the `FILTER:` header line shown at the top of + the buffer when a filter is active. Default: links to `DiagnosticWarn`. To override a group in your colorscheme or config: >lua @@ -376,25 +758,308 @@ To override a group in your colorscheme or config: >lua < ============================================================================== -HEALTH CHECK *pending-health* +LUA API *pending-api* -Run |:checkhealth| pending to verify your setup: >vim - :checkhealth pending +The following functions are available on `require('pending')` for use in +statuslines, autocmds, and other integrations. + + *pending.counts()* +pending.counts() + Returns a table of current task counts: >lua + { + overdue = 2, -- pending tasks past their due date/time + today = 1, -- pending tasks due today (not yet overdue) + pending = 10, -- total pending tasks (all statuses) + priority = 3, -- pending tasks with priority > 0 + next_due = "2026-03-01", -- earliest future due date, or nil + } +< + The counts are read from a module-local cache that is invalidated on every + `:w`, toggle, date change, archive, undo, and sync. The first call triggers + a lazy `store.load()` if the store has not been loaded yet. + + Done, deleted, and `someday` sentinel-dated tasks are excluded from the + `overdue` and `today` counts. The `someday` sentinel is the value of + `someday_date` in |pending-config| (default `9999-12-30`). + + *pending.statusline()* +pending.statusline() + Returns a pre-formatted string suitable for embedding in a statusline: + + - `"2 overdue, 1 today"` when both overdue and today counts are non-zero + - `"2 overdue"` when only overdue + - `"1 today"` when only today + - `""` (empty string) when nothing is actionable + + *pending.has_due()* +pending.has_due() + Returns `true` when `overdue > 0` or `today > 0`. Useful as a conditional + for statusline components that should only render when tasks need attention. + + *PendingStatusChanged* +PendingStatusChanged + A |User| autocmd event fired after every count recomputation. Use this to + trigger statusline refreshes or notifications: >lua + vim.api.nvim_create_autocmd('User', { + pattern = 'PendingStatusChanged', + callback = function() + vim.cmd.redrawstatus() + end, + }) < -Checks performed: ~ -- Config loads without error -- Reports active configuration values (data path, default view, default - category, date format, date syntax) -- Whether the data directory exists (warning if not yet created) -- Whether the data file exists and can be parsed; reports total task count -- Whether `curl` is available (required for Google Calendar sync) -- Whether `openssl` is available (required for OAuth PKCE) +============================================================================== +RECIPES *pending-recipes* + +Configure blink.cmp to use pending.nvim's omnifunc as a completion source: >lua + require('blink.cmp').setup({ + sources = { + per_filetype = { + pending = { 'omni', 'buffer' }, + }, + }, + }) +< + +Lualine integration: >lua + require('lualine').setup({ + sections = { + lualine_x = { + { + function() return require('pending').statusline() end, + cond = function() return require('pending').has_due() end, + }, + }, + }, + }) +< + +Heirline integration: >lua + local Pending = { + condition = function() return require('pending').has_due() end, + provider = function() return require('pending').statusline() end, + } +< + +Manual statusline: >vim + set statusline+=%{%v:lua.require('pending').statusline()%} +< + +Startup notification: >lua + vim.api.nvim_create_autocmd('User', { + pattern = 'PendingStatusChanged', + once = true, + callback = function() + local c = require('pending').counts() + if c.overdue > 0 then + vim.notify(c.overdue .. ' overdue task(s)') + end + end, + }) +< + +Event-driven statusline refresh: >lua + vim.api.nvim_create_autocmd('User', { + pattern = 'PendingStatusChanged', + callback = function() + vim.cmd.redrawstatus() + end, + }) +< + +Nerd font icons: >lua + vim.g.pending = { + icons = { + due = '', + recur = '󰁯', + category = '', + }, + } +< + +Open tasks in a new tab on startup: >lua + vim.api.nvim_create_autocmd('VimEnter', { + callback = function() + vim.cmd.PendingTab() + end, + }) +< + +============================================================================== +SYNC BACKENDS *pending-sync-backend* + +Sync backends are Lua modules under `lua/pending/sync/.lua`. Each +backend is exposed as a top-level `:Pending` subcommand: >vim + :Pending gtasks {action} + :Pending gcal {action} +< + +Each module returns a table conforming to the backend interface: >lua + + ---@class pending.SyncBackend + ---@field name string + ---@field auth fun(): nil + ---@field push? fun(): nil + ---@field pull? fun(): nil + ---@field sync? fun(): nil + ---@field health? fun(): nil +< + +Required fields: ~ + {name} Backend identifier (matches the filename). + {sync} Main sync action. Called by `:Pending `. + {auth} Authorization flow. Called by `:Pending auth`. + +Optional fields: ~ + {push} Push-only action. Called by `:Pending push`. + {pull} Pull-only action. Called by `:Pending pull`. + {health} Called by `:checkhealth pending` to report backend-specific + diagnostics (e.g. checking for external tools). + +Backend-specific configuration goes under `sync.` in |pending-config|. + +============================================================================== +GOOGLE CALENDAR *pending-gcal* + +pending.nvim can push tasks with due dates to Google Calendar as all-day +events. Each pending.nvim category maps to a Google Calendar of the same +name. Calendars are created automatically on first push. This is a one-way +push; changes made in Google Calendar are not pulled back. + +Configuration: >lua + vim.g.pending = { + sync = { + gcal = {}, + }, + } +< + +No configuration is required to get started — bundled OAuth credentials are +used by default. Run `:Pending gcal auth` and the browser opens immediately. + + *pending.GcalConfig* +Fields: ~ + {client_id} (string, optional) + OAuth client ID. When set together with + {client_secret}, these take priority over the + credentials file and bundled defaults. + + {client_secret} (string, optional) + OAuth client secret. See {client_id}. + + {credentials_path} (string, optional) + Path to an OAuth client secret JSON file downloaded + from the Google Cloud Console. Default: + `stdpath('data')..'/pending/gcal_credentials.json'`. + The file may be in the `installed` wrapper format + that Google provides or as a bare credentials object. + +Credential resolution: ~ +Credentials are resolved in order: +1. `client_id` + `client_secret` config fields (highest priority). +2. JSON file at `credentials_path` (or the default path). +3. Bundled credentials shipped with the plugin (always available). + +OAuth flow: ~ +On the first `:Pending gcal` call the plugin detects that no refresh token +exists and opens the Google authorization URL in the browser using +|vim.ui.open()|. A temporary local HTTP server listens on port 18392 for the +OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used. After +the user grants consent, the +authorization code is exchanged for tokens and the refresh token is stored at +`stdpath('data')/pending/gcal_tokens.json` with mode `600`. Subsequent syncs +use the stored refresh token and refresh the access token automatically when +it is about to expire. + +`:Pending gcal push` behavior: ~ +For each task in the store: +- A pending task with a due date and no existing event: a new all-day event is + created in the calendar matching the task's category. The event ID and + calendar ID are stored in the task's `_extra` table. +- A pending task with a due date and an existing event: the event summary and + date are updated in place. +- A done or deleted task with an existing event: the event is deleted. +- A pending task with no due date that had an existing event: the event is + deleted. + +============================================================================== +GOOGLE TASKS *pending-gtasks* + +pending.nvim can sync tasks bidirectionally with Google Tasks. Each +pending.nvim category maps to a Google Tasks list of the same name. Lists are +created automatically on first sync. + +Configuration: >lua + vim.g.pending = { + sync = { + gtasks = {}, + }, + } +< + +No configuration is required to get started — bundled OAuth credentials are +used by default. Run `:Pending gtasks auth` and the browser opens immediately. + + *pending.GtasksConfig* +Fields: ~ + {client_id} (string, optional) + OAuth client ID. When set together with + {client_secret}, these take priority over the + credentials file and bundled defaults. + + {client_secret} (string, optional) + OAuth client secret. See {client_id}. + + {credentials_path} (string, optional) + Path to an OAuth client secret JSON file downloaded + from the Google Cloud Console. Default: + `stdpath('data')..'/pending/gtasks_credentials.json'`. + Accepts the `installed` wrapper format or a bare + credentials object. + +Credential resolution: ~ +Same three-tier resolution as the gcal backend (see |pending-gcal|). + +OAuth flow: ~ +Same PKCE flow as the gcal backend; listens on port 18393. Tokens are stored +at `stdpath('data')/pending/gtasks_tokens.json`. Run `:Pending gtasks auth` +to authorize; subsequent syncs refresh the token automatically. + +`:Pending gtasks` actions: ~ + +`:Pending gtasks` (or `:Pending gtasks sync`) runs push then pull. Use +`:Pending gtasks push` or `:Pending gtasks pull` to run only one direction. + +Push (local → Google Tasks, `:Pending gtasks push`): +- Pending task with no `_gtasks_task_id`: created in the matching list. +- Pending task with an existing ID: updated in Google Tasks. +- Done task with an existing ID: marked `completed` in Google Tasks. +- Deleted task with an existing ID: deleted from Google Tasks. + +Pull (Google Tasks → local, `:Pending gtasks pull`): +- GTasks task already known (matched by `_gtasks_task_id`): updated locally + if `gtasks.updated` timestamp is newer than `task.modified`. +- GTasks task not known locally: created as a new pending.nvim task in the + category matching the list name. + +Field mapping: ~ + {title} ↔ task description + {status} `needsAction` ↔ `pending`, `completed` ↔ `done` + {due} date-only; time component ignored (GTasks limitation) + {notes} serializes extra fields: `pri:1 rec:weekly` + +The `notes` field is used exclusively for pending.nvim metadata. Any existing +notes on tasks created outside pending.nvim are parsed for known tokens and +the remainder is ignored. + +Recurrence (`rec:`) is stored in notes for round-tripping but is not +expanded by Google Tasks (GTasks has no recurrence API). ============================================================================== DATA FORMAT *pending-data* -Tasks are stored as JSON at `data_path`. The file is safe to edit by hand and +Tasks are stored as JSON at the active store path (see +|pending-store-resolution|). The file is safe to edit by hand and is forward-compatible — unknown fields are preserved on every read/write cycle via the `_extra` table. @@ -414,6 +1079,8 @@ Task fields: ~ {category} (string) Category name. Defaults to `default_category`. {priority} (integer) `1` for priority tasks, `0` otherwise. {due} (string) ISO date string `YYYY-MM-DD`, or absent. + {recur} (string) Recurrence shorthand (e.g. `weekly`), or absent. + {recur_mode} (string) `'scheduled'` or `'completion'`, or absent. {entry} (string) ISO 8601 UTC timestamp of creation. {modified} (string) ISO 8601 UTC timestamp of last modification. {end} (string) ISO 8601 UTC timestamp of completion or deletion. @@ -421,7 +1088,8 @@ Task fields: ~ Any field not in the list above is preserved in `_extra` and written back on save. This is used internally to store the Google Calendar event ID -(`_gcal_event_id`) and allows third-party tooling to annotate tasks without +(`_gcal_event_id`) and Google Tasks IDs (`_gtasks_task_id`, +`_gtasks_list_id`), and allows third-party tooling to annotate tasks without data loss. The `version` field is checked on load. If the file version is newer than the @@ -429,4 +1097,10 @@ version the plugin supports, loading is aborted with an error message asking you to update the plugin. ============================================================================== - vim:tw=78:ts=8:ft=help:norl: +HEALTH CHECK *pending-health* + +Run |:checkhealth| pending to verify your setup: >vim + :checkhealth pending +< + +============================================================================== diff --git a/flake.nix b/flake.nix index da16aea..f895154 100644 --- a/flake.nix +++ b/flake.nix @@ -13,9 +13,12 @@ ... }: 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 { + formatter = forEachSystem (pkgs: pkgs.nixfmt-tree); + devShells = forEachSystem (pkgs: { default = pkgs.mkShell { packages = [ diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index d11254b..8fdcbe1 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -1,10 +1,12 @@ local config = require('pending.config') -local store = require('pending.store') local views = require('pending.views') ---@class pending.buffer local M = {} +---@type pending.Store? +local _store = nil + ---@type integer? local task_bufnr = nil ---@type integer? @@ -16,6 +18,10 @@ local current_view = nil local _meta = {} ---@type table> local _fold_state = {} +---@type string[] +local _filter_predicates = {} +---@type table +local _hidden_ids = {} ---@return pending.LineMeta[] function M.meta() @@ -37,12 +43,50 @@ function M.current_view_name() return current_view 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 +function M.hidden_ids() + return _hidden_ids +end + +---@param predicates string[] +---@param hidden table +---@return nil +function M.set_filter(predicates, hidden) + _filter_predicates = predicates + _hidden_ids = hidden +end + +---@return nil function M.clear_winid() task_winid = nil end +---@return nil 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 + local wins = vim.api.nvim_list_wins() + if #wins == 1 then + vim.cmd.enew() + else vim.api.nvim_win_close(task_winid, false) end task_winid = nil @@ -55,19 +99,13 @@ local function set_buf_options(bufnr) vim.bo[bufnr].swapfile = false vim.bo[bufnr].filetype = 'pending' vim.bo[bufnr].modifiable = true + vim.bo[bufnr].omnifunc = 'v:lua.require("pending.complete").omnifunc' end ---@param winid integer local function set_win_options(winid) vim.wo[winid].conceallevel = 3 vim.wo[winid].concealcursor = 'nvic' - vim.wo[winid].wrap = false - vim.wo[winid].number = false - vim.wo[winid].relativenumber = false - vim.wo[winid].signcolumn = 'no' - vim.wo[winid].foldcolumn = '0' - vim.wo[winid].spell = false - vim.wo[winid].cursorline = true vim.wo[winid].winfixheight = true end @@ -77,7 +115,7 @@ local function setup_syntax(bufnr) vim.cmd([[ syntax clear syntax match taskId /^\/\d\+\// conceal - syntax match taskHeader /^## .*$/ contains=taskId + syntax match taskHeader /^# .*$/ contains=taskId syntax match taskCheckbox /\[!\]/ contained containedin=taskLine syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox ]]) @@ -85,6 +123,7 @@ local function setup_syntax(bufnr) end ---@param above boolean +---@return nil function M.open_line(above) local bufnr = task_bufnr if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then @@ -117,29 +156,34 @@ end ---@param bufnr integer ---@param line_meta pending.LineMeta[] local function apply_extmarks(bufnr, line_meta) + local icons = config.get().icons vim.api.nvim_buf_clear_namespace(bufnr, task_ns, 0, -1) for i, m in ipairs(line_meta) do local row = i - 1 - if m.type == 'task' then + if m.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, task_ns, row, 0, { + end_col = #line, + hl_group = 'PendingFilter', + }) + elseif m.type == 'task' then local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue' - if m.show_category then - local virt_text - if m.category and m.due then - 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 } } + local virt_parts = {} + if m.show_category and m.category then + table.insert(virt_parts, { icons.category .. ' ' .. m.category, 'PendingHeader' }) + end + if m.recur then + table.insert(virt_parts, { icons.recur .. ' ' .. m.recur, 'PendingRecur' }) + end + if m.due then + table.insert(virt_parts, { icons.due .. ' ' .. m.due, due_hl }) + end + if #virt_parts > 0 then + for p = 1, #virt_parts - 1 do + virt_parts[p][1] = virt_parts[p][1] .. ' ' 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 = virt_parts, virt_text_pos = 'eol', }) end @@ -151,12 +195,32 @@ local function apply_extmarks(bufnr, line_meta) 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, task_ns, 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, task_ns, row, 0, { end_col = #line, hl_group = 'PendingHeader', }) + vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { + virt_text = { { icons.category .. ' ', 'PendingHeader' } }, + virt_text_pos = 'overlay', + priority = 100, + }) end end end @@ -167,6 +231,8 @@ local function setup_highlights() 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, '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 local function snapshot_folds(bufnr) @@ -212,6 +278,7 @@ local function restore_folds(bufnr) end ---@param bufnr? integer +---@return nil function M.render(bufnr) bufnr = bufnr or task_bufnr if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then @@ -219,8 +286,15 @@ function M.render(bufnr) end current_view = current_view or config.get().default_view - vim.api.nvim_buf_set_name(bufnr, 'pending://' .. current_view) - local tasks = store.active_tasks() + local view_label = current_view == 'priority' and 'queue' or current_view + 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 if current_view == 'priority' then @@ -229,6 +303,11 @@ function M.render(bufnr) lines, line_meta = views.category_view(tasks) 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 snapshot_folds(bufnr) @@ -256,6 +335,7 @@ function M.render(bufnr) restore_folds(bufnr) end +---@return nil function M.toggle_view() if current_view == 'category' then current_view = 'priority' @@ -268,7 +348,9 @@ end ---@return integer bufnr function M.open() setup_highlights() - store.load() + if _store then + _store:load() + end if task_winid and vim.api.nvim_win_is_valid(task_winid) then vim.api.nvim_set_current_win(task_winid) diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua new file mode 100644 index 0000000..9ed4971 --- /dev/null +++ b/lua/pending/complete.lua @@ -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 +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 diff --git a/lua/pending/config.lua b/lua/pending/config.lua index b61f44a..f488e41 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -1,6 +1,43 @@ +---@class pending.Icons +---@field pending string +---@field done string +---@field priority string +---@field due string +---@field recur string +---@field category string + ---@class pending.GcalConfig ----@field calendar? string ---@field credentials_path? string +---@field client_id? string +---@field client_secret? string + +---@class pending.GtasksConfig +---@field credentials_path? string +---@field client_id? string +---@field client_secret? string + +---@class pending.SyncConfig +---@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 ---@field data_path string @@ -8,9 +45,14 @@ ---@field default_category string ---@field date_format string ---@field date_syntax string +---@field recur_syntax string +---@field someday_date string ---@field category_order? string[] ---@field drawer_height? integer ----@field gcal? pending.GcalConfig +---@field debug? boolean +---@field keymaps pending.Keymaps +---@field sync? pending.SyncConfig +---@field icons pending.Icons ---@class pending.config local M = {} @@ -22,7 +64,37 @@ local defaults = { default_category = 'Todo', date_format = '%b %d', date_syntax = 'due', + recur_syntax = 'rec', + someday_date = '9999-12-30', category_order = {}, + keymaps = { + close = 'q', + toggle = '', + view = '', + 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? @@ -38,6 +110,7 @@ function M.get() return _resolved end +---@return nil function M.reset() _resolved = nil end diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 85f083c..5df332f 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -1,6 +1,5 @@ local config = require('pending.config') local parse = require('pending.parse') -local store = require('pending.store') ---@class pending.ParsedEntry ---@field type 'task'|'header'|'blank' @@ -10,6 +9,8 @@ local store = require('pending.store') ---@field status? string ---@field category? string ---@field due? string +---@field rec? string +---@field rec_mode? string ---@field lnum integer ---@class pending.diff @@ -25,8 +26,13 @@ end function M.parse_buffer(lines) local result = {} 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 line = lines[i] local id, body = line:match('^/(%d+)/(- %[.%] .*)$') if not id then body = line:match('^(- %[.%] .*)$') @@ -48,11 +54,13 @@ function M.parse_buffer(lines) status = status, category = metadata.cat or current_category or config.get().default_category, due = metadata.due, + rec = metadata.rec, + rec_mode = metadata.rec_mode, lnum = i, }) end - elseif line:match('^## (.+)$') then - current_category = line:match('^## (.+)$') + elseif line:match('^# (.+)$') then + current_category = line:match('^# (.+)$') table.insert(result, { type = 'header', category = current_category, lnum = i }) end end @@ -61,10 +69,13 @@ function M.parse_buffer(lines) end ---@param lines string[] -function M.apply(lines) +---@param s pending.Store +---@param hidden_ids? table +---@return nil +function M.apply(lines, s, hidden_ids) local parsed = M.parse_buffer(lines) local now = timestamp() - local data = store.data() + local data = s:data() local old_by_id = {} 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 seen_ids[entry.id] then - store.add({ + s:add({ description = entry.description, category = entry.category, priority = entry.priority, due = entry.due, + recur = entry.rec, + recur_mode = entry.rec_mode, order = order_counter, }) else @@ -108,10 +121,20 @@ function M.apply(lines) task.priority = entry.priority changed = true end - if task.due ~= entry.due then + if entry.due ~= nil and task.due ~= entry.due then task.due = entry.due changed = true 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 task.status = entry.status if entry.status == 'done' then @@ -130,11 +153,13 @@ function M.apply(lines) end end else - store.add({ + s:add({ description = entry.description, category = entry.category, priority = entry.priority, due = entry.due, + recur = entry.rec, + recur_mode = entry.rec_mode, order = order_counter, }) end @@ -143,14 +168,14 @@ function M.apply(lines) end 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['end'] = now task.modified = now end end - store.save() + s:save() end return M diff --git a/lua/pending/health.lua b/lua/pending/health.lua index 8a12da4..d3dbe2c 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -1,5 +1,6 @@ local M = {} +---@return nil function M.check() vim.health.start('pending.nvim') @@ -11,40 +12,55 @@ function M.check() local cfg = config.get() vim.health.ok('Config loaded') - vim.health.info('Data path: ' .. cfg.data_path) - local data_dir = vim.fn.fnamemodify(cfg.data_path, ':h') - if vim.fn.isdirectory(data_dir) == 1 then - vim.health.ok('Data directory exists: ' .. data_dir) - else - vim.health.warn('Data directory does not exist yet: ' .. data_dir) + local store_ok, store = pcall(require, 'pending.store') + if not store_ok then + vim.health.error('Failed to load pending.store') + return end - if vim.fn.filereadable(cfg.data_path) == 1 then - local store_ok, store = pcall(require, 'pending.store') - if store_ok then - local load_ok, err = pcall(store.load) - if load_ok then - local tasks = store.tasks() - vim.health.ok('Data file loaded: ' .. #tasks .. ' tasks') - else - vim.health.error('Failed to load data file: ' .. tostring(err)) + local resolved_path = store.resolve_path() + vim.health.info('Store path: ' .. resolved_path) + if resolved_path ~= cfg.data_path then + vim.health.info('(project-local store; global path: ' .. cfg.data_path .. ')') + end + + if vim.fn.filereadable(resolved_path) == 1 then + local s = store.new(resolved_path) + local load_ok, err = pcall(function() + s:load() + end) + 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 - 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 diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 14b9c24..a83692d 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -3,22 +3,200 @@ local diff = require('pending.diff') local parse = require('pending.parse') local store = require('pending.store') +---@class pending.Counts +---@field overdue integer +---@field today integer +---@field pending integer +---@field priority integer +---@field next_due? string + ---@class pending.init local M = {} ----@type pending.Task[][] -local _undo_states = {} local UNDO_MAX = 20 +---@type pending.Counts? +local _counts = nil + +---@type pending.Store? +local _store = nil + +---@return pending.Store +local function get_store() + if not _store then + _store = store.new(store.resolve_path()) + end + return _store +end + +---@return pending.Store +function M.store() + return get_store() +end + +---@return nil +function M._recompute_counts() + local cfg = require('pending.config').get() + local someday = cfg.someday_date + local overdue = 0 + local today = 0 + local pending = 0 + local priority = 0 + local next_due = nil ---@type string? + local today_str = os.date('%Y-%m-%d') --[[@as string]] + + for _, task in ipairs(get_store():active_tasks()) do + if task.status == 'pending' then + pending = pending + 1 + if task.priority > 0 then + priority = priority + 1 + end + if task.due and task.due ~= someday then + if parse.is_overdue(task.due) then + overdue = overdue + 1 + elseif parse.is_today(task.due) then + today = today + 1 + end + local date_part = task.due:match('^(%d%d%d%d%-%d%d%-%d%d)') or task.due + if date_part >= today_str and (not next_due or task.due < next_due) then + next_due = task.due + end + end + end + end + + _counts = { + overdue = overdue, + today = today, + pending = pending, + priority = priority, + next_due = next_due, + } + + vim.api.nvim_exec_autocmds('User', { pattern = 'PendingStatusChanged' }) +end + +---@return nil +local function _save_and_notify() + get_store():save() + M._recompute_counts() +end + +---@return pending.Counts +function M.counts() + if not _counts then + get_store():load() + M._recompute_counts() + end + return _counts --[[@as pending.Counts]] +end + +---@return string +function M.statusline() + local c = M.counts() + if c.overdue > 0 and c.today > 0 then + return c.overdue .. ' overdue, ' .. c.today .. ' today' + elseif c.overdue > 0 then + return c.overdue .. ' overdue' + elseif c.today > 0 then + return c.today .. ' today' + end + return '' +end + +---@return boolean +function M.has_due() + local c = M.counts() + return c.overdue > 0 or c.today > 0 +end + +---@param tasks pending.Task[] +---@param predicates string[] +---@return table +local function compute_hidden_ids(tasks, predicates) + if #predicates == 0 then + return {} + end + local hidden = {} + for _, task in ipairs(tasks) do + local visible = true + for _, pred in ipairs(predicates) do + local cat_val = pred:match('^cat:(.+)$') + if cat_val then + if task.category ~= cat_val then + visible = false + break + end + elseif pred == 'overdue' then + if not (task.status == 'pending' and task.due and parse.is_overdue(task.due)) then + visible = false + break + end + elseif pred == 'today' then + if not (task.status == 'pending' and task.due and parse.is_today(task.due)) then + visible = false + break + end + elseif pred == 'priority' then + if not (task.priority and task.priority > 0) then + visible = false + break + end + elseif pred == 'done' then + if task.status ~= 'done' then + visible = false + break + end + elseif pred == 'pending' then + if task.status ~= 'pending' then + visible = false + break + end + end + end + if not visible then + hidden[task.id] = true + end + end + return hidden +end + ---@return integer bufnr function M.open() + local s = get_store() + buffer.set_store(s) local bufnr = buffer.open() M._setup_autocmds(bufnr) M._setup_buf_mappings(bufnr) return bufnr end +---@param pred_str string +---@return nil +function M.filter(pred_str) + if pred_str == 'clear' or pred_str == '' then + buffer.set_filter({}, {}) + local bufnr = buffer.bufnr() + if bufnr then + buffer.render(bufnr) + end + return + end + local predicates = {} + for word in pred_str:gmatch('%S+') do + table.insert(predicates, word) + end + local tasks = get_store():active_tasks() + local hidden = compute_hidden_ids(tasks, predicates) + buffer.set_filter(predicates, hidden) + local bufnr = buffer.bufnr() + if bufnr then + buffer.render(bufnr) + end +end + ---@param bufnr integer +---@return nil function M._setup_autocmds(bufnr) local group = vim.api.nvim_create_augroup('PendingBuffer', { clear = true }) vim.api.nvim_create_autocmd('BufWriteCmd', { @@ -33,7 +211,7 @@ function M._setup_autocmds(bufnr) buffer = bufnr, callback = function() if not vim.bo[bufnr].modified then - store.load() + get_store():load() buffer.render(bufnr) end end, @@ -49,63 +227,166 @@ function M._setup_autocmds(bufnr) end ---@param bufnr integer +---@return nil function M._setup_buf_mappings(bufnr) + local cfg = require('pending.config').get() + local km = cfg.keymaps local opts = { buffer = bufnr, silent = true } - vim.keymap.set('n', 'q', function() - buffer.close() - end, opts) - vim.keymap.set('n', '', function() - buffer.close() - end, opts) - vim.keymap.set('n', '', function() - M.toggle_complete() - end, opts) - vim.keymap.set('n', '', function() - buffer.toggle_view() - end, opts) - vim.keymap.set('n', 'g?', function() - M.show_help() - end, opts) - vim.keymap.set('n', '!', function() - M.toggle_priority() - end, opts) - vim.keymap.set('n', 'D', function() - M.prompt_date() - end, opts) - vim.keymap.set('n', 'U', function() - M.undo_write() - end, opts) - vim.keymap.set('n', 'o', function() - buffer.open_line(false) - end, opts) - vim.keymap.set('n', 'O', function() - buffer.open_line(true) - end, opts) + + ---@type table + local actions = { + close = function() + buffer.close() + end, + toggle = function() + M.toggle_complete() + end, + view = function() + buffer.toggle_view() + end, + priority = function() + M.toggle_priority() + end, + date = function() + M.prompt_date() + end, + undo = function() + M.undo_write() + end, + filter = function() + vim.ui.input({ prompt = 'Filter: ' }, function(input) + if input then + M.filter(input) + end + end) + end, + open_line = function() + buffer.open_line(false) + end, + open_line_above = function() + buffer.open_line(true) + end, + } + + for name, fn in pairs(actions) do + local key = km[name] + if key and key ~= false then + vim.keymap.set('n', key --[[@as string]], fn, opts) + end + end + + local textobj = require('pending.textobj') + + ---@type table + local textobjs = { + a_task = { + modes = { 'o', 'x' }, + fn = textobj.a_task, + visual_fn = textobj.a_task_visual, + }, + i_task = { + modes = { 'o', 'x' }, + fn = textobj.i_task, + visual_fn = textobj.i_task_visual, + }, + a_category = { + modes = { 'o', 'x' }, + fn = textobj.a_category, + visual_fn = textobj.a_category_visual, + }, + i_category = { + modes = { 'o', 'x' }, + fn = textobj.i_category, + visual_fn = textobj.i_category_visual, + }, + } + + for name, spec in pairs(textobjs) do + local key = km[name] + if key and key ~= false then + for _, mode in ipairs(spec.modes) do + if mode == 'x' and spec.visual_fn then + vim.keymap.set(mode, key --[[@as string]], function() + spec.visual_fn(vim.v.count1) + end, opts) + else + vim.keymap.set(mode, key --[[@as string]], function() + spec.fn(vim.v.count1) + end, opts) + end + end + end + end + + ---@type table + local motions = { + next_header = textobj.next_header, + prev_header = textobj.prev_header, + next_task = textobj.next_task, + prev_task = textobj.prev_task, + } + + for name, fn in pairs(motions) do + local key = km[name] + if cfg.debug then + vim.notify( + ('[pending] mapping motion %s → %s (buf=%d)'):format(name, key or 'nil', bufnr), + vim.log.levels.INFO + ) + end + if key and key ~= false then + vim.keymap.set({ 'n', 'x', 'o' }, key --[[@as string]], function() + fn(vim.v.count1) + end, opts) + end + end end ---@param bufnr integer +---@return nil function M._on_write(bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local snapshot = store.snapshot() - table.insert(_undo_states, snapshot) - if #_undo_states > UNDO_MAX then - table.remove(_undo_states, 1) + local predicates = buffer.filter_predicates() + if lines[1] and lines[1]:match('^FILTER:') then + local pred_str = lines[1]:match('^FILTER:%s*(.*)$') or '' + predicates = {} + for word in pred_str:gmatch('%S+') do + table.insert(predicates, word) + end + lines = vim.list_slice(lines, 2) + elseif #buffer.filter_predicates() > 0 then + predicates = {} end - diff.apply(lines) + local s = get_store() + local tasks = s:active_tasks() + local hidden = compute_hidden_ids(tasks, predicates) + buffer.set_filter(predicates, hidden) + local snapshot = s:snapshot() + local stack = s:undo_stack() + table.insert(stack, snapshot) + if #stack > UNDO_MAX then + table.remove(stack, 1) + end + diff.apply(lines, s, hidden) + M._recompute_counts() buffer.render(bufnr) end +---@return nil function M.undo_write() - if #_undo_states == 0 then + local s = get_store() + local stack = s:undo_stack() + if #stack == 0 then vim.notify('Nothing to undo.', vim.log.levels.WARN) return end - local state = table.remove(_undo_states) - store.replace_tasks(state) - store.save() + local state = table.remove(stack) + s:replace_tasks(state) + _save_and_notify() buffer.render(buffer.bufnr()) end +---@return nil function M.toggle_complete() local bufnr = buffer.bufnr() if not bufnr then @@ -120,16 +401,30 @@ function M.toggle_complete() if not id then return end - local task = store.get(id) + local s = get_store() + local task = s:get(id) if not task then return end if task.status == 'done' then - store.update(id, { status = 'pending', ['end'] = vim.NIL }) + s:update(id, { status = 'pending', ['end'] = vim.NIL }) else - store.update(id, { status = 'done' }) + if task.recur and task.due then + local recur = require('pending.recur') + local mode = task.recur_mode or 'scheduled' + local next_date = recur.next_due(task.due, task.recur, mode) + s:add({ + description = task.description, + category = task.category, + priority = task.priority, + due = next_date, + recur = task.recur, + recur_mode = task.recur_mode, + }) + end + s:update(id, { status = 'done' }) end - store.save() + _save_and_notify() buffer.render(bufnr) for lnum, m in ipairs(buffer.meta()) do if m.id == id then @@ -139,6 +434,7 @@ function M.toggle_complete() end end +---@return nil function M.toggle_priority() local bufnr = buffer.bufnr() if not bufnr then @@ -153,13 +449,14 @@ function M.toggle_priority() if not id then return end - local task = store.get(id) + local s = get_store() + local task = s:get(id) if not task then return end local new_priority = task.priority > 0 and 0 or 1 - store.update(id, { priority = new_priority }) - store.save() + s:update(id, { priority = new_priority }) + _save_and_notify() buffer.render(bufnr) for lnum, m in ipairs(buffer.meta()) do if m.id == id then @@ -169,6 +466,7 @@ function M.toggle_priority() end end +---@return nil function M.prompt_date() local bufnr = buffer.bufnr() if not bufnr then @@ -183,7 +481,7 @@ function M.prompt_date() if not id then return end - vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input) + vim.ui.input({ prompt = 'Due date (today, +3d, fri@2pm, etc.): ' }, function(input) if not input then return end @@ -192,35 +490,42 @@ function M.prompt_date() local resolved = parse.resolve_date(due) if resolved then due = resolved - elseif not due:match('^%d%d%d%d%-%d%d%-%d%d$') then - vim.notify('Invalid date format. Use YYYY-MM-DD.', vim.log.levels.ERROR) + elseif + not due:match('^%d%d%d%d%-%d%d%-%d%d$') + and not due:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$') + then + vim.notify('Invalid date format. Use YYYY-MM-DD or YYYY-MM-DDThh:mm.', vim.log.levels.ERROR) return end end - store.update(id, { due = due }) - store.save() + get_store():update(id, { due = due }) + _save_and_notify() buffer.render(bufnr) end) end ---@param text string +---@return nil function M.add(text) if not text or text == '' then vim.notify('Usage: :Pending add ', vim.log.levels.ERROR) return end - store.load() + local s = get_store() + s:load() local description, metadata = parse.command_add(text) if not description or description == '' then vim.notify('Pending must have a description.', vim.log.levels.ERROR) return end - store.add({ + s:add({ description = description, category = metadata.cat, due = metadata.due, + recur = metadata.rec, + recur_mode = metadata.rec_mode, }) - store.save() + _save_and_notify() local bufnr = buffer.bufnr() if bufnr and vim.api.nvim_buf_is_valid(bufnr) then buffer.render(bufnr) @@ -228,25 +533,54 @@ function M.add(text) vim.notify('Pending added: ' .. description) end -function M.sync() - local ok, gcal = pcall(require, 'pending.sync.gcal') +---@type string[] +local SYNC_BACKENDS = { 'gcal', 'gtasks' } + +---@type table +local SYNC_BACKEND_SET = {} +for _, b in ipairs(SYNC_BACKENDS) do + SYNC_BACKEND_SET[b] = true +end + +---@param backend_name string +---@param action? string +---@return nil +local function run_sync(backend_name, action) + local ok, backend = pcall(require, 'pending.sync.' .. backend_name) if not ok then - vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR) + vim.notify('Unknown sync backend: ' .. backend_name, vim.log.levels.ERROR) return end - gcal.sync() + if not action or action == '' then + local actions = {} + for k, v in pairs(backend) do + if type(v) == 'function' and k:sub(1, 1) ~= '_' then + table.insert(actions, k) + end + end + table.sort(actions) + vim.notify(backend_name .. ' actions: ' .. table.concat(actions, ', '), vim.log.levels.INFO) + return + end + if type(backend[action]) ~= 'function' then + vim.notify(backend_name .. " backend has no '" .. action .. "' action", vim.log.levels.ERROR) + return + end + backend[action]() end ---@param days? integer +---@return nil function M.archive(days) days = days or 30 local cutoff = os.time() - (days * 86400) - local tasks = store.tasks() + local s = get_store() + local tasks = s:tasks() local archived = 0 local kept = {} for _, task in ipairs(tasks) do if (task.status == 'done' or task.status == 'deleted') and task['end'] then - local y, mo, d, h, mi, s = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$') + local y, mo, d, h, mi, sec = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$') if y then local t = os.time({ year = tonumber(y) --[[@as integer]], @@ -254,7 +588,7 @@ function M.archive(days) day = tonumber(d) --[[@as integer]], hour = tonumber(h) --[[@as integer]], min = tonumber(mi) --[[@as integer]], - sec = tonumber(s) --[[@as integer]], + sec = tonumber(sec) --[[@as integer]], }) if t < cutoff then archived = archived + 1 @@ -265,8 +599,8 @@ function M.archive(days) table.insert(kept, task) ::skip:: end - store.replace_tasks(kept) - store.save() + s:replace_tasks(kept) + _save_and_notify() vim.notify('Archived ' .. archived .. ' tasks.') local bufnr = buffer.bufnr() if bufnr and vim.api.nvim_buf_is_valid(bufnr) then @@ -274,8 +608,8 @@ function M.archive(days) end end +---@return nil function M.due() - local today = os.date('%Y-%m-%d') --[[@as string]] local bufnr = buffer.bufnr() local is_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr) local meta = is_valid and buffer.meta() or nil @@ -283,9 +617,14 @@ function M.due() if meta and bufnr then for lnum, m in ipairs(meta) do - if m.type == 'task' and m.raw_due and m.status ~= 'done' and m.raw_due <= today then - local task = store.get(m.id or 0) - local label = m.raw_due < today and '[OVERDUE] ' or '[DUE] ' + if + m.type == 'task' + and m.raw_due + and m.status ~= 'done' + and (parse.is_overdue(m.raw_due) or parse.is_today(m.raw_due)) + then + local task = get_store():get(m.id or 0) + local label = parse.is_overdue(m.raw_due) and '[OVERDUE] ' or '[DUE] ' table.insert(qf_items, { bufnr = bufnr, lnum = lnum, @@ -295,10 +634,15 @@ function M.due() end end else - store.load() - for _, task in ipairs(store.active_tasks()) do - if task.status == 'pending' and task.due and task.due <= today then - local label = task.due < today and '[OVERDUE] ' or '[DUE] ' + local s = get_store() + s:load() + for _, task in ipairs(s:active_tasks()) do + if + task.status == 'pending' + and task.due + and (parse.is_overdue(task.due) or parse.is_today(task.due)) + then + local label = parse.is_overdue(task.due) and '[OVERDUE] ' or '[DUE] ' local text = label .. task.description if task.category then text = text .. ' [' .. task.category .. ']' @@ -317,68 +661,194 @@ function M.due() vim.cmd('copen') end -function M.show_help() +---@param token string +---@return string|nil field +---@return any value +---@return string|nil err +local function parse_edit_token(token) + local recur = require('pending.recur') local cfg = require('pending.config').get() local dk = cfg.date_syntax or 'due' - local lines = { - 'pending.nvim keybindings', - '', - ' Toggle complete/uncomplete', - ' Switch category/priority view', - '! Toggle urgent', - 'D Set due date', - 'U Undo last write', - 'o / O Add new task line', - 'dd Delete task line (on :w)', - 'p / P Paste (duplicates get new IDs)', - 'zc / zo Fold/unfold category (category view)', - ':w Save all changes', - '', - ':Pending add Quick-add task', - ':Pending add Cat: Quick-add with category', - ':Pending due Show overdue/due qflist', - ':Pending sync Push to Google Calendar', - ':Pending archive [days] Purge old done tasks', - ':Pending undo Undo last write', - '', - 'Inline metadata (on new lines before :w):', - ' ' .. dk .. ':YYYY-MM-DD Set due date', - ' cat:Name Set category', - '', - 'Due date input:', - ' today, tomorrow, +Nd, mon-sun', - ' Empty input clears due date', - '', - 'Highlights:', - ' PendingOverdue overdue tasks (red)', - ' PendingPriority [!] urgent tasks', - '', - 'Press q or to close', - } - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - vim.bo[buf].modifiable = false - vim.bo[buf].bufhidden = 'wipe' - local width = 54 - local height = #lines - local win = vim.api.nvim_open_win(buf, true, { - relative = 'editor', - width = width, - height = height, - col = math.floor((vim.o.columns - width) / 2), - row = math.floor((vim.o.lines - height) / 2), - style = 'minimal', - border = 'rounded', - }) - vim.keymap.set('n', 'q', function() - vim.api.nvim_win_close(win, true) - end, { buffer = buf, silent = true }) - vim.keymap.set('n', '', function() - vim.api.nvim_win_close(win, true) - end, { buffer = buf, silent = true }) + local rk = cfg.recur_syntax or 'rec' + + if token == '+!' then + return 'priority', 1, nil + end + if token == '-!' then + return 'priority', 0, nil + end + if token == '-due' or token == '-' .. dk then + return 'due', vim.NIL, nil + end + if token == '-cat' then + return 'category', vim.NIL, nil + end + if token == '-rec' or token == '-' .. rk then + return 'recur', vim.NIL, nil + end + local due_val = token:match('^' .. vim.pesc(dk) .. ':(.+)$') + if due_val then + local resolved = parse.resolve_date(due_val) + if resolved then + return 'due', resolved, nil + end + if + due_val:match('^%d%d%d%d%-%d%d%-%d%d$') or due_val:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$') + then + return 'due', due_val, nil + end + return nil, + nil, + 'Invalid date: ' .. due_val .. '. Use YYYY-MM-DD, today, tomorrow, +Nd, weekday names, etc.' + end + + local cat_val = token:match('^cat:(.+)$') + if cat_val then + return 'category', cat_val, nil + end + + local rec_val = token:match('^' .. vim.pesc(rk) .. ':(.+)$') + if rec_val then + local raw_spec = rec_val + local rec_mode = nil + if raw_spec:sub(1, 1) == '!' then + rec_mode = 'completion' + raw_spec = raw_spec:sub(2) + end + if not recur.validate(raw_spec) then + return nil, nil, 'Invalid recurrence pattern: ' .. rec_val + end + return 'recur', { spec = raw_spec, mode = rec_mode }, nil + end + + return nil, + nil, + 'Unknown operation: ' + .. token + .. '. Valid: ' + .. dk + .. ':, cat:, ' + .. rk + .. ':, +!, -!, -' + .. dk + .. ', -cat, -' + .. rk +end + +---@param id_str string +---@param rest string +---@return nil +function M.edit(id_str, rest) + if not id_str or id_str == '' then + vim.notify( + 'Usage: :Pending edit [due:] [cat:] [rec:] [+!] [-!] [-due] [-cat] [-rec]', + vim.log.levels.ERROR + ) + return + end + + local id = tonumber(id_str) + if not id then + vim.notify('Invalid task ID: ' .. id_str, vim.log.levels.ERROR) + return + end + + local s = get_store() + s:load() + local task = s:get(id) + if not task then + vim.notify('No task with ID ' .. id .. '.', vim.log.levels.ERROR) + return + end + + if not rest or rest == '' then + vim.notify( + 'Usage: :Pending edit [due:] [cat:] [rec:] [+!] [-!] [-due] [-cat] [-rec]', + vim.log.levels.ERROR + ) + return + end + + local tokens = {} + for tok in rest:gmatch('%S+') do + table.insert(tokens, tok) + end + + local updates = {} + local feedback = {} + + for _, tok in ipairs(tokens) do + local field, value, err = parse_edit_token(tok) + if err then + vim.notify(err, vim.log.levels.ERROR) + return + end + if field == 'recur' then + if value == vim.NIL then + updates.recur = vim.NIL + updates.recur_mode = vim.NIL + table.insert(feedback, 'recurrence removed') + else + updates.recur = value.spec + updates.recur_mode = value.mode + table.insert(feedback, 'recurrence set to ' .. value.spec) + end + elseif field == 'due' then + if value == vim.NIL then + updates.due = vim.NIL + table.insert(feedback, 'due date removed') + else + updates.due = value + table.insert(feedback, 'due date set to ' .. tostring(value)) + end + elseif field == 'category' then + if value == vim.NIL then + updates.category = vim.NIL + table.insert(feedback, 'category removed') + else + updates.category = value + table.insert(feedback, 'category set to ' .. tostring(value)) + end + elseif field == 'priority' then + updates.priority = value + table.insert(feedback, value == 1 and 'priority added' or 'priority removed') + end + end + + local snapshot = s:snapshot() + local stack = s:undo_stack() + table.insert(stack, snapshot) + if #stack > UNDO_MAX then + table.remove(stack, 1) + end + + s:update(id, updates) + + _save_and_notify() + + local bufnr = buffer.bufnr() + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + buffer.render(bufnr) + end + + vim.notify('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', ')) +end + +---@return nil +function M.init() + local path = vim.fn.getcwd() .. '/.pending.json' + if vim.fn.filereadable(path) == 1 then + vim.notify('pending.nvim: .pending.json already exists', vim.log.levels.WARN) + return + end + local s = store.new(path) + s:load() + s:save() + vim.notify('pending.nvim: created ' .. path) end ---@param args string +---@return nil function M.command(args) if not args or args == '' then M.open() @@ -387,18 +857,36 @@ function M.command(args) local cmd, rest = args:match('^(%S+)%s*(.*)') if cmd == 'add' then M.add(rest) - elseif cmd == 'sync' then - M.sync() + elseif cmd == 'edit' then + local id_str, edit_rest = rest:match('^(%S+)%s*(.*)') + M.edit(id_str, edit_rest) + elseif SYNC_BACKEND_SET[cmd] then + local action = rest:match('^(%S+)') + run_sync(cmd, action) elseif cmd == 'archive' then local d = rest ~= '' and tonumber(rest) or nil M.archive(d) elseif cmd == 'due' then M.due() + elseif cmd == 'filter' then + M.filter(rest) elseif cmd == 'undo' then M.undo_write() + elseif cmd == 'init' then + M.init() else vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR) end end +---@return string[] +function M.sync_backends() + return SYNC_BACKENDS +end + +---@return table +function M.sync_backend_set() + return SYNC_BACKEND_SET +end + return M diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index ebe909a..9ce4c0d 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -24,11 +24,92 @@ local function is_valid_date(s) return check.year == yn and check.month == mn and check.day == dn 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 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 + local weekday_map = { sun = 1, mon = 2, @@ -39,45 +120,295 @@ local weekday_map = { sat = 7, } +local month_map = { + jan = 1, + feb = 2, + mar = 3, + apr = 4, + may = 5, + jun = 6, + jul = 7, + aug = 8, + sep = 9, + oct = 10, + nov = 11, + dec = 12, +} + +---@param today osdate +---@return string +local function today_str(today) + return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]] +end + +---@param date_part string +---@param time_suffix? string +---@return string +local function append_time(date_part, time_suffix) + if time_suffix then + return date_part .. 'T' .. time_suffix + end + return date_part +end + ---@param text string ---@return string|nil 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]] - if lower == 'today' then - return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]] + if lower == 'today' or lower == 'eod' then + 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 if lower == 'tomorrow' then - return os.date( - '%Y-%m-%d', - os.time({ year = today.year, month = today.month, day = today.day + 1 }) - ) --[[@as string]] + 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 + + 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 local n = lower:match('^%+(%d+)d$') if n then - return os.date( - '%Y-%m-%d', - os.time({ - year = today.year, - month = today.month, - day = today.day + ( - tonumber(n) --[[@as integer]] - ), - }) - ) --[[@as string]] + 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 + + 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 local target_wday = weekday_map[lower] if target_wday then local current_wday = today.wday local delta = (target_wday - current_wday) % 7 - return os.date( - '%Y-%m-%d', - os.time({ year = today.year, month = today.month, day = today.day + delta }) - ) --[[@as string]] + 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 return nil @@ -85,7 +416,7 @@ end ---@param text string ---@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) local tokens = {} for token in text:gmatch('%S+') do @@ -95,8 +426,10 @@ function M.body(text) local metadata = {} local i = #tokens 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 rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$' while i >= 1 do local token = tokens[i] @@ -105,7 +438,7 @@ function M.body(text) if metadata.due then break end - if not is_valid_date(due_val) then + if not is_valid_datetime(due_val) then break end metadata.due = due_val @@ -131,7 +464,25 @@ function M.body(text) metadata.cat = cat_val i = i - 1 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 @@ -148,7 +499,7 @@ end ---@param text string ---@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) local cat_prefix = text:match('^(%S.-):%s') if cat_prefix then @@ -165,4 +516,39 @@ function M.command_add(text) return M.body(text) 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 diff --git a/lua/pending/recur.lua b/lua/pending/recur.lua new file mode 100644 index 0000000..9c647aa --- /dev/null +++ b/lua/pending/recur.lua @@ -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 +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 diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 5838414..5a5b370 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -7,6 +7,8 @@ local config = require('pending.config') ---@field category? string ---@field priority integer ---@field due? string +---@field recur? string +---@field recur_mode? 'scheduled'|'completion' ---@field entry string ---@field modified string ---@field end? string @@ -17,21 +19,26 @@ local config = require('pending.config') ---@field version integer ---@field next_id integer ---@field tasks pending.Task[] +---@field undo pending.Task[][] + +---@class pending.Store +---@field path string +---@field _data pending.Data? +local Store = {} +Store.__index = Store ---@class pending.store local M = {} local SUPPORTED_VERSION = 1 ----@type pending.Data? -local _data = nil - ---@return pending.Data local function empty_data() return { version = SUPPORTED_VERSION, next_id = 1, tasks = {}, + undo = {}, } end @@ -56,6 +63,8 @@ local known_fields = { category = true, priority = true, due = true, + recur = true, + recur_mode = true, entry = true, modified = true, ['end'] = true, @@ -81,6 +90,12 @@ local function task_to_table(task) if task.due then t.due = task.due 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 t['end'] = task['end'] end @@ -105,6 +120,8 @@ local function table_to_task(t) category = t.category, priority = t.priority or 0, due = t.due, + recur = t.recur, + recur_mode = t.recur_mode, entry = t.entry, modified = t.modified, ['end'] = t['end'], @@ -123,18 +140,18 @@ local function table_to_task(t) end ---@return pending.Data -function M.load() - local path = config.get().data_path +function Store:load() + local path = self.path local f = io.open(path, 'r') if not f then - _data = empty_data() - return _data + self._data = empty_data() + return self._data end local content = f:read('*a') f:close() if content == '' then - _data = empty_data() - return _data + self._data = empty_data() + return self._data end local ok, decoded = pcall(vim.json.decode, content) if not ok then @@ -149,31 +166,50 @@ function M.load() .. '. Please update the plugin.' ) end - _data = { + self._data = { version = decoded.version or SUPPORTED_VERSION, next_id = decoded.next_id or 1, tasks = {}, + undo = {}, } 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 - 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 -function M.save() - if not _data then +---@return nil +function Store:save() + if not self._data then return end - local path = config.get().data_path + local path = self.path ensure_dir(path) local out = { - version = _data.version, - next_id = _data.next_id, + version = self._data.version, + next_id = self._data.next_id, tasks = {}, + undo = {}, } - for _, task in ipairs(_data.tasks) do + for _, task in ipairs(self._data.tasks) do table.insert(out.tasks, task_to_table(task)) 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 tmp = path .. '.tmp' local f = io.open(tmp, 'w') @@ -190,22 +226,22 @@ function M.save() end ---@return pending.Data -function M.data() - if not _data then - M.load() +function Store:data() + if not self._data then + self:load() end - return _data --[[@as pending.Data]] + return self._data --[[@as pending.Data]] end ---@return pending.Task[] -function M.tasks() - return M.data().tasks +function Store:tasks() + return self:data().tasks end ---@return pending.Task[] -function M.active_tasks() +function Store:active_tasks() local result = {} - for _, task in ipairs(M.tasks()) do + for _, task in ipairs(self:tasks()) do if task.status ~= 'deleted' then table.insert(result, task) end @@ -215,8 +251,8 @@ end ---@param id integer ---@return pending.Task? -function M.get(id) - for _, task in ipairs(M.tasks()) do +function Store:get(id) + for _, task in ipairs(self:tasks()) do if task.id == id then return task end @@ -224,10 +260,10 @@ function M.get(id) return nil 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 -function M.add(fields) - local data = M.data() +function Store:add(fields) + local data = self:data() local now = timestamp() local task = { id = data.next_id, @@ -236,6 +272,8 @@ function M.add(fields) category = fields.category or config.get().default_category, priority = fields.priority or 0, due = fields.due, + recur = fields.recur, + recur_mode = fields.recur_mode, entry = now, modified = now, ['end'] = nil, @@ -250,15 +288,19 @@ end ---@param id integer ---@param fields table ---@return pending.Task? -function M.update(id, fields) - local task = M.get(id) +function Store:update(id, fields) + local task = self:get(id) if not task then return nil end local now = timestamp() for k, v in pairs(fields) do if k ~= 'id' and k ~= 'entry' then - task[k] = v + if v == vim.NIL then + task[k] = nil + else + task[k] = v + end end end task.modified = now @@ -270,14 +312,14 @@ end ---@param id integer ---@return pending.Task? -function M.delete(id) - return M.update(id, { status = 'deleted', ['end'] = timestamp() }) +function Store:delete(id) + return self:update(id, { status = 'deleted', ['end'] = timestamp() }) end ---@param id integer ---@return integer? -function M.find_index(id) - for i, task in ipairs(M.tasks()) do +function Store:find_index(id) + for i, task in ipairs(self:tasks()) do if task.id == id then return i end @@ -286,14 +328,15 @@ function M.find_index(id) end ---@param tasks pending.Task[] -function M.replace_tasks(tasks) - M.data().tasks = tasks +---@return nil +function Store:replace_tasks(tasks) + self:data().tasks = tasks end ---@return pending.Task[] -function M.snapshot() +function Store:snapshot() local result = {} - for _, task in ipairs(M.active_tasks()) do + for _, task in ipairs(self:active_tasks()) do local copy = {} for k, v in pairs(task) do if k ~= '_extra' then @@ -311,13 +354,45 @@ function M.snapshot() return result end ----@param id integer -function M.set_next_id(id) - M.data().next_id = id +---@return pending.Task[][] +function Store:undo_stack() + return self:data().undo end -function M.unload() - _data = nil +---@param stack pending.Task[][] +---@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 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() + local results = vim.fs.find('.pending.json', { + upward = true, + path = vim.fn.getcwd(), + type = 'file', + }) + if results and #results > 0 then + return results[1] + end + return config.get().data_path end return M diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 6635575..44f7742 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -1,384 +1,67 @@ local config = require('pending.config') -local store = require('pending.store') +local oauth = require('pending.sync.oauth') local M = {} +M.name = 'gcal' + 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 ----@field client_id string ----@field client_secret string ----@field redirect_uris? string[] +local client = oauth.new({ + name = 'gcal', + scope = SCOPE, + port = 18392, + config_key = 'gcal', +}) ----@class pending.GcalTokens ----@field access_token string ----@field refresh_token string ----@field expires_in? integer ----@field obtained_at? integer - ----@return table -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) +---@param access_token string +---@return table? name_to_id +---@return string? err +local function get_all_calendars(access_token) + local data, err = oauth.curl_request( + 'GET', + BASE_URL .. '/users/me/calendarList', + oauth.auth_headers(access_token) ) -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 '

Authorization successful

You can close this tab.

' - or '

Authorization failed

' - 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 return nil, err end - + local result = {} for _, item in ipairs(data and data.items or {}) do - if item.summary == cal_name then - return item.id, nil + if item.summary then + result[item.summary] = item.id end end + return result, nil +end - local body = vim.json.encode({ summary = cal_name }) - local created, create_err = - curl_request('POST', BASE_URL .. '/calendars', auth_headers(access_token), body) - if create_err then - return nil, create_err +---@param access_token string +---@param name string +---@param existing table +---@return string? calendar_id +---@return string? err +local function find_or_create_calendar(access_token, name, existing) + if existing[name] then + return existing[name], nil end - - return created and created.id, nil + local body = vim.json.encode({ summary = name }) + 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 ---@param date_str string ---@return string 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 }) + 86400 return os.date('%Y-%m-%d', t) --[[@as string]] @@ -399,10 +82,10 @@ local function create_event(access_token, calendar_id, task) private = { taskId = tostring(task.id) }, }, } - local data, err = curl_request( + local data, err = oauth.curl_request( 'POST', - BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events', - auth_headers(access_token), + BASE_URL .. '/calendars/' .. oauth.url_encode(calendar_id) .. '/events', + oauth.auth_headers(access_token), vim.json.encode(event) ) if err then @@ -422,10 +105,14 @@ local function update_event(access_token, calendar_id, event_id, task) start = { date = task.due }, ['end'] = { date = next_day(task.due or '') }, } - local _, err = curl_request( + local _, err = oauth.curl_request( 'PATCH', - BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id), - auth_headers(access_token), + BASE_URL + .. '/calendars/' + .. oauth.url_encode(calendar_id) + .. '/events/' + .. oauth.url_encode(event_id), + oauth.auth_headers(access_token), vim.json.encode(event) ) return err @@ -436,81 +123,121 @@ end ---@param event_id string ---@return string? err local function delete_event(access_token, calendar_id, event_id) - local _, err = curl_request( + local _, err = oauth.curl_request( 'DELETE', - BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id), - auth_headers(access_token) + BASE_URL + .. '/calendars/' + .. oauth.url_encode(calendar_id) + .. '/events/' + .. oauth.url_encode(event_id), + oauth.auth_headers(access_token) ) return err end -function M.sync() - local access_token = get_access_token() - if not access_token then - return - end +function M.auth() + client:auth() +end - local calendar_id, err = find_or_create_calendar(access_token) - if err or not calendar_id then - vim.notify('pending.nvim: ' .. (err or 'calendar not found'), vim.log.levels.ERROR) - return - end +function M.push() + oauth.async(function() + local access_token = client:get_access_token() + if not access_token then + return + end - local tasks = store.tasks() - local created, updated, deleted = 0, 0, 0 + local calendars, cal_err = get_all_calendars(access_token) + if cal_err or not calendars then + vim.notify('pending.nvim: ' .. (cal_err or 'failed to fetch calendars'), vim.log.levels.ERROR) + return + end - for _, task in ipairs(tasks) do - local extra = task._extra or {} - local event_id = extra['_gcal_event_id'] --[[@as string?]] + local s = require('pending').store() + local created, updated, deleted = 0, 0, 0 - local should_delete = event_id ~= nil - and ( - task.status == 'done' - or task.status == 'deleted' - or (task.status == 'pending' and not task.due) - ) + for _, task in ipairs(s:tasks()) do + local extra = task._extra or {} + local event_id = extra['_gcal_event_id'] --[[@as string?]] + local cal_id = extra['_gcal_calendar_id'] --[[@as string?]] - if should_delete and event_id then - local del_err = delete_event(access_token, calendar_id, event_id) --[[@as string]] - if not del_err then - extra['_gcal_event_id'] = nil - if next(extra) == nil then - task._extra = nil + local should_delete = event_id ~= nil + and cal_id ~= nil + and ( + task.status == 'done' + or task.status == 'deleted' + or (task.status == 'pending' and not task.due) + ) + + if should_delete then + local del_err = + delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]]) + if del_err then + vim.notify('pending.nvim: gcal delete failed: ' .. del_err, vim.log.levels.WARN) else - task._extra = extra - end - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] - deleted = deleted + 1 - end - elseif task.status == 'pending' and task.due then - if event_id then - local upd_err = update_event(access_token, calendar_id, event_id, task) - if not upd_err then - updated = updated + 1 - end - else - local new_id, create_err = create_event(access_token, calendar_id, task) - if not create_err and new_id then - if not task._extra then - task._extra = {} + extra['_gcal_event_id'] = nil + extra['_gcal_calendar_id'] = nil + if next(extra) == nil then + task._extra = nil + else + task._extra = extra end - task._extra['_gcal_event_id'] = new_id task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] - created = created + 1 + 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 + vim.notify('pending.nvim: gcal update failed: ' .. upd_err, vim.log.levels.WARN) + 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 + vim.notify( + 'pending.nvim: gcal calendar failed: ' .. (lid_err or 'unknown'), + vim.log.levels.WARN + ) + else + local new_id, create_err = create_event(access_token, lid, task) + if create_err then + vim.notify('pending.nvim: gcal create failed: ' .. create_err, vim.log.levels.WARN) + 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 = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + created = created + 1 + end + end end end end - end - store.save() - vim.notify( - string.format( - 'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)', - created, - updated, - deleted + 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 + vim.notify( + string.format( + 'pending.nvim: Google Calendar pushed — +%d ~%d -%d', + created, + updated, + deleted + ) ) - ) + end) +end + +---@return nil +function M.health() + oauth.health(M.name) end return M diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua new file mode 100644 index 0000000..a046a51 --- /dev/null +++ b/lua/pending/sync/gtasks.lua @@ -0,0 +1,459 @@ +local config = require('pending.config') +local oauth = require('pending.sync.oauth') + +local M = {} + +M.name = 'gtasks' + +local BASE_URL = 'https://tasks.googleapis.com/tasks/v1' +local SCOPE = 'https://www.googleapis.com/auth/tasks' + +local client = oauth.new({ + name = 'gtasks', + scope = SCOPE, + port = 18393, + config_key = 'gtasks', +}) + +---@param access_token string +---@return table? 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 +---@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 + +---@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 +local function build_id_index(s) + ---@type table + 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 +---@param s pending.Store +---@param now_ts string +---@param by_gtasks_id table +---@return integer created +---@return integer updated +---@return integer deleted +local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local created, updated, deleted = 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 + local err = delete_gtask(access_token, list_id, gtid) + if err then + vim.notify('pending.nvim: gtasks delete failed: ' .. err, vim.log.levels.WARN) + else + if not task._extra then + task._extra = {} + end + task._extra['_gtasks_task_id'] = nil + task._extra['_gtasks_list_id'] = nil + if next(task._extra) == nil then + task._extra = nil + end + task.modified = now_ts + deleted = deleted + 1 + end + elseif task.status ~= 'deleted' then + if gtid and list_id then + local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task)) + if err then + vim.notify('pending.nvim: gtasks update failed: ' .. err, vim.log.levels.WARN) + else + updated = updated + 1 + 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 + vim.notify('pending.nvim: gtasks create failed: ' .. create_err, vim.log.levels.WARN) + 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.modified = now_ts + by_gtasks_id[new_id] = task + created = created + 1 + end + end + end + end + end + return created, updated, deleted +end + +---@param access_token string +---@param tasklists table +---@param s pending.Store +---@param now_ts string +---@param by_gtasks_id table +---@return integer created +---@return integer updated +local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local created, updated = 0, 0 + for list_name, list_id in pairs(tasklists) do + local items, err = list_gtasks(access_token, list_id) + if err then + vim.notify( + 'pending.nvim: error fetching list ' .. list_name .. ': ' .. err, + vim.log.levels.WARN + ) + else + for _, gtask in ipairs(items or {}) do + 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.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, + } + local new_task = s:add(fields) + by_gtasks_id[gtask.id] = new_task + created = created + 1 + end + end + end + end + return created, updated +end + +---@return string? access_token +---@return table? tasklists +---@return pending.Store? store +---@return string? now_ts +local function sync_setup() + local access_token = client:get_access_token() + if not access_token then + return nil + end + local tasklists, tl_err = get_all_tasklists(access_token) + if tl_err or not tasklists then + vim.notify('pending.nvim: ' .. (tl_err or 'failed to fetch task lists'), vim.log.levels.ERROR) + return nil + end + local s = require('pending').store() + local now_ts = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + return access_token, tasklists, s, now_ts +end + +function M.auth() + client:auth() +end + +function M.push() + oauth.async(function() + local access_token, tasklists, s, now_ts = sync_setup() + if not access_token then + return + end + ---@cast tasklists table + ---@cast s pending.Store + ---@cast now_ts string + local by_gtasks_id = build_id_index(s) + local created, updated, deleted = 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 + vim.notify( + string.format('pending.nvim: Google Tasks pushed — +%d ~%d -%d', created, updated, deleted) + ) + end) +end + +function M.pull() + oauth.async(function() + local access_token, tasklists, s, now_ts = sync_setup() + if not access_token then + return + end + ---@cast tasklists table + ---@cast s pending.Store + ---@cast now_ts string + local by_gtasks_id = build_id_index(s) + local created, updated = pull_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 + vim.notify(string.format('pending.nvim: Google Tasks pulled — +%d ~%d', created, updated)) + end) +end + +function M.sync() + oauth.async(function() + local access_token, tasklists, s, now_ts = sync_setup() + if not access_token then + return + end + ---@cast tasklists table + ---@cast s pending.Store + ---@cast now_ts string + local by_gtasks_id = build_id_index(s) + local pushed_create, pushed_update, pushed_delete = + push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local pulled_create, pulled_update = pull_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 + vim.notify( + string.format( + 'pending.nvim: Google Tasks synced — push: +%d ~%d -%d, pull: +%d ~%d', + pushed_create, + pushed_update, + pushed_delete, + pulled_create, + pulled_update + ) + ) + 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 + +---@return nil +function M.health() + oauth.health(M.name) + local tokens = 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 gtasks auth') + end +end + +return M diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua new file mode 100644 index 0000000..c53e3b1 --- /dev/null +++ b/lua/pending/sync/oauth.lua @@ -0,0 +1,405 @@ +local config = require('pending.config') + +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 + +---@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 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 cred_path = backend_cfg.credentials_path + or (vim.fn.stdpath('data') .. '/pending/' .. self.name .. '_credentials.json') + 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 + + 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 + self:auth() + tokens = self: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 = self: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 + +---@return nil +function OAuthClient:auth() + local creds = self:resolve_credentials() + 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=consent' + .. '&code_challenge=' + .. M.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() + local server_closed = false + local function close_server() + if server_closed then + return + end + server_closed = true + server:close() + end + + server:bind('127.0.0.1', port) + 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 '

Authorization successful

You can close this tab.

' + or '

Authorization failed

' + 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) + end) + end + end) + end) + + vim.defer_fn(function() + if not server_closed then + close_server() + vim.notify('pending.nvim: OAuth callback timed out (120s).', vim.log.levels.WARN) + end + end, 120000) +end + +---@param creds pending.OAuthCredentials +---@param code string +---@param code_verifier string +---@param port integer +---@return nil +function OAuthClient:_exchange_code(creds, code, code_verifier, port) + 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 + 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() + self:save_tokens(decoded) + vim.notify('pending.nvim: ' .. self.name .. ' authorized successfully.') +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 + +return M diff --git a/lua/pending/textobj.lua b/lua/pending/textobj.lua new file mode 100644 index 0000000..62d6db3 --- /dev/null +++ b/lua/pending/textobj.lua @@ -0,0 +1,384 @@ +local buffer = require('pending.buffer') +local config = require('pending.config') + +---@class pending.textobj +local M = {} + +---@param ... any +---@return nil +local function dbg(...) + if config.get().debug then + vim.notify('[pending.textobj] ' .. string.format(...), vim.log.levels.INFO) + end +end + +---@param lnum integer +---@param meta pending.LineMeta[] +---@return string +local function get_line_from_buf(lnum, meta) + local _ = meta + local bufnr = buffer.bufnr() + if not bufnr then + return '' + end + local lines = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, false) + return lines[1] or '' +end + +---@param line string +---@return integer start_col +---@return integer end_col +function M.inner_task_range(line) + local prefix_end = line:find('/') and select(2, line:find('^/%d+/%- %[.%] ')) + if not prefix_end then + prefix_end = select(2, line:find('^%- %[.%] ')) or 0 + end + local start_col = prefix_end + 1 + + local dk = config.get().date_syntax or 'due' + local rk = config.get().recur_syntax or 'rec' + local dk_pat = '^' .. vim.pesc(dk) .. ':%S+$' + local rk_pat = '^' .. vim.pesc(rk) .. ':%S+$' + + local rest = line:sub(start_col) + local words = {} + for word in rest:gmatch('%S+') do + table.insert(words, word) + end + + local i = #words + while i >= 1 do + local word = words[i] + if word:match(dk_pat) or word:match('^cat:%S+$') or word:match(rk_pat) then + i = i - 1 + else + break + end + end + + if i < 1 then + return start_col, start_col + end + + local desc = table.concat(words, ' ', 1, i) + local end_col = start_col + #desc - 1 + return start_col, end_col +end + +---@param row integer +---@param meta pending.LineMeta[] +---@return integer? header_row +---@return integer? last_row +function M.category_bounds(row, meta) + if not meta or #meta == 0 then + return nil, nil + end + + local header_row = nil + local m = meta[row] + if not m then + return nil, nil + end + + if m.type == 'header' then + header_row = row + else + for r = row, 1, -1 do + if meta[r] and meta[r].type == 'header' then + header_row = r + break + end + end + end + + if not header_row then + return nil, nil + end + + local last_row = header_row + local total = #meta + for r = header_row + 1, total do + if meta[r].type == 'header' then + break + end + last_row = r + end + + return header_row, last_row +end + +---@param count integer +---@return nil +function M.a_task(count) + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local m = meta[row] + if not m or m.type ~= 'task' then + return + end + + local start_row = row + local end_row = row + count = math.max(1, count) + for _ = 2, count do + local next_row = end_row + 1 + if next_row > #meta then + break + end + if meta[next_row] and meta[next_row].type == 'task' then + end_row = next_row + else + break + end + end + + vim.cmd('normal! ' .. start_row .. 'GV' .. end_row .. 'G') +end + +---@param count integer +---@return nil +function M.a_task_visual(count) + vim.cmd('normal! \27') + M.a_task(count) +end + +---@param count integer +---@return nil +function M.i_task(count) + local _ = count + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local m = meta[row] + if not m or m.type ~= 'task' then + return + end + + local line = get_line_from_buf(row, meta) + local start_col, end_col = M.inner_task_range(line) + if start_col > end_col then + return + end + + vim.api.nvim_win_set_cursor(0, { row, start_col - 1 }) + vim.cmd('normal! v') + vim.api.nvim_win_set_cursor(0, { row, end_col - 1 }) +end + +---@param count integer +---@return nil +function M.i_task_visual(count) + vim.cmd('normal! \27') + M.i_task(count) +end + +---@param count integer +---@return nil +function M.a_category(count) + local _ = count + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + local view = buffer.current_view_name() + if view == 'priority' then + return + end + + local row = vim.api.nvim_win_get_cursor(0)[1] + local header_row, last_row = M.category_bounds(row, meta) + if not header_row or not last_row then + return + end + + local start_row = header_row + if header_row > 1 and meta[header_row - 1] and meta[header_row - 1].type == 'blank' then + start_row = header_row - 1 + end + local end_row = last_row + if last_row < #meta and meta[last_row + 1] and meta[last_row + 1].type == 'blank' then + end_row = last_row + 1 + end + + vim.cmd('normal! ' .. start_row .. 'GV' .. end_row .. 'G') +end + +---@param count integer +---@return nil +function M.a_category_visual(count) + vim.cmd('normal! \27') + M.a_category(count) +end + +---@param count integer +---@return nil +function M.i_category(count) + local _ = count + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + local view = buffer.current_view_name() + if view == 'priority' then + return + end + + local row = vim.api.nvim_win_get_cursor(0)[1] + local header_row, last_row = M.category_bounds(row, meta) + if not header_row or not last_row then + return + end + + local first_task = nil + local last_task = nil + for r = header_row + 1, last_row do + if meta[r] and meta[r].type == 'task' then + if not first_task then + first_task = r + end + last_task = r + end + end + + if not first_task or not last_task then + return + end + + vim.cmd('normal! ' .. first_task .. 'GV' .. last_task .. 'G') +end + +---@param count integer +---@return nil +function M.i_category_visual(count) + vim.cmd('normal! \27') + M.i_category(count) +end + +---@param count integer +---@return nil +function M.next_header(count) + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + local view = buffer.current_view_name() + if view == 'priority' then + return + end + + local row = vim.api.nvim_win_get_cursor(0)[1] + dbg('next_header: cursor=%d, meta_len=%d, view=%s', row, #meta, view or 'nil') + local found = 0 + count = math.max(1, count) + for r = row + 1, #meta do + if meta[r] and meta[r].type == 'header' then + found = found + 1 + dbg( + 'next_header: found header at row=%d, cat=%s, found=%d/%d', + r, + meta[r].category or '?', + found, + count + ) + if found == count then + vim.api.nvim_win_set_cursor(0, { r, 0 }) + dbg('next_header: cursor set to row=%d, actual=%d', r, vim.api.nvim_win_get_cursor(0)[1]) + return + end + else + dbg('next_header: row=%d type=%s', r, meta[r] and meta[r].type or 'nil') + end + end + dbg('next_header: no header found after row=%d', row) +end + +---@param count integer +---@return nil +function M.prev_header(count) + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + local view = buffer.current_view_name() + if view == 'priority' then + return + end + + local row = vim.api.nvim_win_get_cursor(0)[1] + dbg('prev_header: cursor=%d, meta_len=%d', row, #meta) + local found = 0 + count = math.max(1, count) + for r = row - 1, 1, -1 do + if meta[r] and meta[r].type == 'header' then + found = found + 1 + dbg( + 'prev_header: found header at row=%d, cat=%s, found=%d/%d', + r, + meta[r].category or '?', + found, + count + ) + if found == count then + vim.api.nvim_win_set_cursor(0, { r, 0 }) + return + end + end + end +end + +---@param count integer +---@return nil +function M.next_task(count) + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + + local row = vim.api.nvim_win_get_cursor(0)[1] + dbg('next_task: cursor=%d, meta_len=%d', row, #meta) + local found = 0 + count = math.max(1, count) + for r = row + 1, #meta do + if meta[r] and meta[r].type == 'task' then + found = found + 1 + if found == count then + dbg('next_task: jumping to row=%d', r) + vim.api.nvim_win_set_cursor(0, { r, 0 }) + return + end + end + end + dbg('next_task: no task found after row=%d', row) +end + +---@param count integer +---@return nil +function M.prev_task(count) + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + + local row = vim.api.nvim_win_get_cursor(0)[1] + dbg('prev_task: cursor=%d, meta_len=%d', row, #meta) + local found = 0 + count = math.max(1, count) + for r = row - 1, 1, -1 do + if meta[r] and meta[r].type == 'task' then + found = found + 1 + if found == count then + dbg('prev_task: jumping to row=%d', r) + vim.api.nvim_win_set_cursor(0, { r, 0 }) + return + end + end + end + dbg('prev_task: no task found before row=%d', row) +end + +return M diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 7bcfaca..87fcee1 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -1,7 +1,8 @@ local config = require('pending.config') +local parse = require('pending.parse') ---@class pending.LineMeta ----@field type 'task'|'header'|'blank' +---@field type 'task'|'header'|'blank'|'filter' ---@field id? integer ---@field due? string ---@field raw_due? string @@ -10,6 +11,7 @@ local config = require('pending.config') ---@field overdue? boolean ---@field show_category? boolean ---@field priority? integer +---@field recur? string ---@class pending.views local M = {} @@ -20,7 +22,10 @@ local function format_due(due) if not due then return nil end - local y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') + 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 return due end @@ -29,7 +34,11 @@ local function format_due(due) month = tonumber(m) --[[@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 ---@param tasks pending.Task[] @@ -73,7 +82,6 @@ end ---@return string[] lines ---@return pending.LineMeta[] meta function M.category_view(tasks) - local today = os.date('%Y-%m-%d') --[[@as string]] local by_cat = {} local cat_order = {} local cat_seen = {} @@ -125,7 +133,7 @@ function M.category_view(tasks) table.insert(lines, '') table.insert(meta, { type = 'blank' }) end - table.insert(lines, '## ' .. cat) + table.insert(lines, '# ' .. cat) table.insert(meta, { type = 'header', category = cat }) local all = {} @@ -148,7 +156,9 @@ function M.category_view(tasks) raw_due = task.due, status = task.status, category = cat, - overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil, + overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) + or nil, + recur = task.recur, }) end end @@ -160,7 +170,6 @@ end ---@return string[] lines ---@return pending.LineMeta[] meta function M.priority_view(tasks) - local today = os.date('%Y-%m-%d') --[[@as string]] local pending = {} local done = {} @@ -198,8 +207,9 @@ function M.priority_view(tasks) raw_due = task.due, status = task.status, category = task.category, - overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil, + overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) or nil, show_category = true, + recur = task.recur, }) end diff --git a/plugin/pending.lua b/plugin/pending.lua index 465ee65..162dfd7 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -3,16 +3,225 @@ if vim.g.loaded_pending then end 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) require('pending').command(opts.args) end, { + bar = true, nargs = '*', complete = function(arg_lead, cmd_line) - local subcmds = { 'add', 'sync', 'archive', 'due', 'undo' } + local pending = require('pending') + local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'init', '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 - return vim.tbl_filter(function(s) - return s:find(arg_lead, 1, true) == 1 - end, subcmds) + return filter_candidates(arg_lead, subcmds) + end + 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+edit') then + return complete_edit(arg_lead, cmd_line) + 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) ~= '_' then + table.insert(actions, k) + end + end + table.sort(actions) + return filter_candidates(arg_lead, actions) end return {} end, @@ -22,6 +231,10 @@ vim.keymap.set('n', '(pending-open)', function() require('pending').open() end) +vim.keymap.set('n', '(pending-close)', function() + require('pending.buffer').close() +end) + vim.keymap.set('n', '(pending-toggle)', function() require('pending').toggle_complete() end) @@ -37,3 +250,65 @@ end) vim.keymap.set('n', '(pending-date)', function() require('pending').prompt_date() end) + +vim.keymap.set('n', '(pending-undo)', function() + require('pending').undo_write() +end) + +vim.keymap.set('n', '(pending-filter)', function() + vim.ui.input({ prompt = 'Filter: ' }, function(input) + if input then + require('pending').filter(input) + end + end) +end) + +vim.keymap.set('n', '(pending-open-line)', function() + require('pending.buffer').open_line(false) +end) + +vim.keymap.set('n', '(pending-open-line-above)', function() + require('pending.buffer').open_line(true) +end) + +vim.keymap.set({ 'o', 'x' }, '(pending-a-task)', function() + require('pending.textobj').a_task(vim.v.count1) +end) + +vim.keymap.set({ 'o', 'x' }, '(pending-i-task)', function() + require('pending.textobj').i_task(vim.v.count1) +end) + +vim.keymap.set({ 'o', 'x' }, '(pending-a-category)', function() + require('pending.textobj').a_category(vim.v.count1) +end) + +vim.keymap.set({ 'o', 'x' }, '(pending-i-category)', function() + require('pending.textobj').i_category(vim.v.count1) +end) + +vim.keymap.set({ 'n', 'x', 'o' }, '(pending-next-header)', function() + require('pending.textobj').next_header(vim.v.count1) +end) + +vim.keymap.set({ 'n', 'x', 'o' }, '(pending-prev-header)', function() + require('pending.textobj').prev_header(vim.v.count1) +end) + +vim.keymap.set({ 'n', 'x', 'o' }, '(pending-next-task)', function() + require('pending.textobj').next_task(vim.v.count1) +end) + +vim.keymap.set({ 'n', 'x', 'o' }, '(pending-prev-task)', function() + require('pending.textobj').prev_task(vim.v.count1) +end) + +vim.keymap.set('n', '(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, {}) diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..854fe09 --- /dev/null +++ b/scripts/ci.sh @@ -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 diff --git a/spec/archive_spec.lua b/spec/archive_spec.lua index df1a912..e7046fa 100644 --- a/spec/archive_spec.lua +++ b/spec/archive_spec.lua @@ -1,87 +1,96 @@ require('spec.helpers') local config = require('pending.config') -local store = require('pending.store') describe('archive', function() local tmpdir - local pending = require('pending') + local pending before_each(function() tmpdir = vim.fn.tempname() vim.fn.mkdir(tmpdir, 'p') vim.g.pending = { data_path = tmpdir .. '/tasks.json' } config.reset() - store.unload() - store.load() + 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('removes done tasks completed more than 30 days ago', function() - local t = store.add({ description = 'Old done task' }) - store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + local s = pending.store() + local t = s:add({ description = 'Old done task' }) + s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) pending.archive() - assert.are.equal(0, #store.active_tasks()) + assert.are.equal(0, #s:active_tasks()) end) 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 t = store.add({ description = 'Recent done task' }) - store.update(t.id, { status = 'done', ['end'] = recent_end }) + local t = s:add({ description = 'Recent done task' }) + s:update(t.id, { status = 'done', ['end'] = recent_end }) pending.archive() - local active = store.active_tasks() + local active = s:active_tasks() assert.are.equal(1, #active) assert.are.equal('Recent done task', active[1].description) end) 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 t = store.add({ description = 'Old for 7 days' }) - store.update(t.id, { status = 'done', ['end'] = eight_days_ago }) + local t = s:add({ description = 'Old for 7 days' }) + s:update(t.id, { status = 'done', ['end'] = eight_days_ago }) pending.archive(7) - assert.are.equal(0, #store.active_tasks()) + assert.are.equal(0, #s:active_tasks()) end) 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 t = store.add({ description = 'Recent for 7 days' }) - store.update(t.id, { status = 'done', ['end'] = five_days_ago }) + local t = s:add({ description = 'Recent for 7 days' }) + s:update(t.id, { status = 'done', ['end'] = five_days_ago }) pending.archive(7) - local active = store.active_tasks() + local active = s:active_tasks() assert.are.equal(1, #active) end) 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() - local active = store.active_tasks() + local active = s:active_tasks() assert.are.equal(1, #active) assert.are.equal('pending', active[1].status) end) it('removes deleted tasks past the cutoff', function() - local t = store.add({ description = 'Old deleted task' }) - store.update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' }) + local s = pending.store() + local t = s:add({ description = 'Old deleted task' }) + s:update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' }) pending.archive() - local all = store.tasks() + local all = s:tasks() assert.are.equal(0, #all) end) 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 t = store.add({ description = 'Recent deleted' }) - store.update(t.id, { status = 'deleted', ['end'] = recent_end }) + local t = s:add({ description = 'Recent deleted' }) + s:update(t.id, { status = 'deleted', ['end'] = recent_end }) pending.archive() - local all = store.tasks() + local all = s:tasks() assert.are.equal(1, #all) end) it('reports the correct count in vim.notify', function() + local s = pending.store() local messages = {} local orig_notify = vim.notify vim.notify = function(msg, ...) @@ -89,11 +98,11 @@ describe('archive', function() return orig_notify(msg, ...) end - local t1 = store.add({ description = 'Old 1' }) - local t2 = store.add({ description = 'Old 2' }) - store.add({ description = 'Keep' }) - store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) - store.update(t2.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + local t1 = s:add({ description = 'Old 1' }) + local t2 = s:add({ description = 'Old 2' }) + s:add({ description = 'Keep' }) + s:update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + s:update(t2.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) pending.archive() @@ -110,16 +119,17 @@ describe('archive', function() end) it('leaves only kept tasks in store.active_tasks after archive', function() - local t1 = store.add({ description = 'Old done' }) - store.add({ description = 'Keep pending' }) + local s = pending.store() + 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 t3 = store.add({ description = 'Keep recent done' }) - store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) - store.update(t3.id, { status = 'done', ['end'] = recent_end }) + local t3 = s:add({ description = 'Keep recent done' }) + s:update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + s:update(t3.id, { status = 'done', ['end'] = recent_end }) pending.archive() - local active = store.active_tasks() + local active = s:active_tasks() assert.are.equal(2, #active) local descs = {} for _, task in ipairs(active) do @@ -130,11 +140,11 @@ describe('archive', function() end) it('persists archived tasks to disk after unload/reload', function() - local t = store.add({ description = 'Archived task' }) - store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + local s = pending.store() + local t = s:add({ description = 'Archived task' }) + s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) pending.archive() - store.unload() - store.load() - assert.are.equal(0, #store.active_tasks()) + s:load() + assert.are.equal(0, #s:active_tasks()) end) end) diff --git a/spec/complete_spec.lua b/spec/complete_spec.lua new file mode 100644 index 0000000..98547e8 --- /dev/null +++ b/spec/complete_spec.lua @@ -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) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index fda2165..01d8aac 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -1,35 +1,31 @@ require('spec.helpers') -local config = require('pending.config') local store = require('pending.store') describe('diff', function() local tmpdir + local s local diff = require('pending.diff') before_each(function() tmpdir = vim.fn.tempname() vim.fn.mkdir(tmpdir, 'p') - vim.g.pending = { data_path = tmpdir .. '/tasks.json' } - config.reset() - store.unload() - store.load() + s = store.new(tmpdir .. '/tasks.json') + s:load() end) after_each(function() vim.fn.delete(tmpdir, 'rf') - vim.g.pending = nil - config.reset() end) describe('parse_buffer', function() it('parses headers and tasks', function() local lines = { - '## School', + '# School', '/1/- [ ] Do homework', '/2/- [!] Read chapter 5', '', - '## Errands', + '# Errands', '/3/- [ ] Buy groceries', } local result = diff.parse_buffer(lines) @@ -48,7 +44,7 @@ describe('diff', function() it('handles new tasks without ids', function() local lines = { - '## Inbox', + '# Inbox', '- [ ] New task here', } local result = diff.parse_buffer(lines) @@ -60,7 +56,7 @@ describe('diff', function() it('inline cat: token overrides header category', function() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Buy milk cat:Work', } local result = diff.parse_buffer(lines) @@ -69,9 +65,28 @@ describe('diff', function() assert.are.equal('Work', result[2].category) 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() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Buy milk due:2026-03-15', } local result = diff.parse_buffer(lines) @@ -84,139 +99,192 @@ describe('diff', function() describe('apply', function() it('creates new tasks from buffer lines', function() local lines = { - '## Inbox', + '# Inbox', '- [ ] First task', '- [ ] Second task', } - diff.apply(lines) - store.unload() - store.load() - local tasks = store.active_tasks() + diff.apply(lines, s) + s:load() + local tasks = s:active_tasks() assert.are.equal(2, #tasks) assert.are.equal('First task', tasks[1].description) assert.are.equal('Second task', tasks[2].description) end) it('deletes tasks removed from buffer', function() - store.add({ description = 'Keep me' }) - store.add({ description = 'Delete me' }) - store.save() + s:add({ description = 'Keep me' }) + s:add({ description = 'Delete me' }) + s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Keep me', } - diff.apply(lines) - store.unload() - store.load() - local active = store.active_tasks() + diff.apply(lines, s) + s:load() + local active = s:active_tasks() assert.are.equal(1, #active) assert.are.equal('Keep me', active[1].description) - local deleted = store.get(2) + local deleted = s:get(2) assert.are.equal('deleted', deleted.status) end) it('updates modified tasks', function() - store.add({ description = 'Original' }) - store.save() + s:add({ description = 'Original' }) + s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Renamed', } - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) + diff.apply(lines, s) + s:load() + local task = s:get(1) assert.are.equal('Renamed', task.description) end) 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' - store.save() + s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Renamed', } - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) + diff.apply(lines, s) + s:load() + local task = s:get(1) assert.are.equal('Renamed', task.description) assert.is_not.equal('2020-01-01T00:00:00Z', task.modified) end) it('handles duplicate ids as copies', function() - store.add({ description = 'Original' }) - store.save() + s:add({ description = 'Original' }) + s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Original', '/1/- [ ] Copy of original', } - diff.apply(lines) - store.unload() - store.load() - local tasks = store.active_tasks() + diff.apply(lines, s) + s:load() + local tasks = s:active_tasks() assert.are.equal(2, #tasks) end) it('moves tasks between categories', function() - store.add({ description = 'Moving task', category = 'Inbox' }) - store.save() + s:add({ description = 'Moving task', category = 'Inbox' }) + s:save() local lines = { - '## Work', + '# Work', '/1/- [ ] Moving task', } - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) + diff.apply(lines, s) + s:load() + local task = s:get(1) assert.are.equal('Work', task.category) end) it('does not update modified when task is unchanged', function() - store.add({ description = 'Stable task', category = 'Inbox' }) - store.save() + s:add({ description = 'Stable task', category = 'Inbox' }) + s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Stable task', } - diff.apply(lines) - store.unload() - store.load() - local modified_after_first = store.get(1).modified - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) + diff.apply(lines, s) + s:load() + local modified_after_first = s:get(1).modified + diff.apply(lines, s) + s:load() + local task = s:get(1) assert.are.equal(modified_after_first, task.modified) end) - it('clears due when removed from buffer line', function() - store.add({ description = 'Pay bill', due = '2026-03-15' }) - store.save() + it('preserves due when not present in buffer line', function() + s:add({ description = 'Pay bill', due = '2026-03-15' }) + s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Pay bill', } - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) - assert.is_nil(task.due) + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal('2026-03-15', 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) it('clears priority when [N] is removed from buffer line', function() - store.add({ description = 'Task name', priority = 1 }) - store.save() + s:add({ description = 'Task name', priority = 1 }) + s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Task name', } - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) + diff.apply(lines, s) + s:load() + local task = s:get(1) assert.are.equal(0, task.priority) end) end) diff --git a/spec/edit_spec.lua b/spec/edit_spec.lua new file mode 100644 index 0000000..08ef9e0 --- /dev/null +++ b/spec/edit_spec.lua @@ -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) diff --git a/spec/filter_spec.lua b/spec/filter_spec.lua new file mode 100644 index 0000000..5e00b60 --- /dev/null +++ b/spec/filter_spec.lua @@ -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) diff --git a/spec/gtasks_spec.lua b/spec/gtasks_spec.lua new file mode 100644 index 0000000..19328d9 --- /dev/null +++ b/spec/gtasks_spec.lua @@ -0,0 +1,178 @@ +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) diff --git a/spec/icons_spec.lua b/spec/icons_spec.lua new file mode 100644 index 0000000..47b518c --- /dev/null +++ b/spec/icons_spec.lua @@ -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) diff --git a/spec/oauth_spec.lua b/spec/oauth_spec.lua new file mode 100644 index 0000000..520227d --- /dev/null +++ b/spec/oauth_spec.lua @@ -0,0 +1,230 @@ +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 c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) + local creds = c:resolve_credentials() + 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) diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index ca8047c..bc313b0 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -154,6 +154,240 @@ describe('parse', function() local result = parse.resolve_date('') assert.is_nil(result) 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) describe('command_add', function() diff --git a/spec/recur_spec.lua b/spec/recur_spec.lua new file mode 100644 index 0000000..53b7478 --- /dev/null +++ b/spec/recur_spec.lua @@ -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) diff --git a/spec/status_spec.lua b/spec/status_spec.lua new file mode 100644 index 0000000..e2d4223 --- /dev/null +++ b/spec/status_spec.lua @@ -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) diff --git a/spec/store_spec.lua b/spec/store_spec.lua index bb6266d..0bed750 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -5,31 +5,30 @@ local store = require('pending.store') describe('store', function() local tmpdir + local s before_each(function() tmpdir = vim.fn.tempname() vim.fn.mkdir(tmpdir, 'p') - vim.g.pending = { data_path = tmpdir .. '/tasks.json' } - config.reset() - store.unload() + s = store.new(tmpdir .. '/tasks.json') + s:load() end) after_each(function() vim.fn.delete(tmpdir, 'rf') - vim.g.pending = nil config.reset() end) describe('load', 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.next_id) assert.are.same({}, data.tasks) end) it('loads existing data', function() - local path = config.get().data_path + local path = tmpdir .. '/tasks.json' local f = io.open(path, 'w') f:write(vim.json.encode({ version = 1, @@ -52,7 +51,7 @@ describe('store', function() }, })) f:close() - local data = store.load() + local data = s:load() assert.are.equal(3, data.next_id) assert.are.equal(2, #data.tasks) assert.are.equal('Pending one', data.tasks[1].description) @@ -60,7 +59,7 @@ describe('store', function() end) it('preserves unknown fields', function() - local path = config.get().data_path + local path = tmpdir .. '/tasks.json' local f = io.open(path, 'w') f:write(vim.json.encode({ version = 1, @@ -77,8 +76,8 @@ describe('store', function() }, })) f:close() - store.load() - local task = store.get(1) + s:load() + local task = s:get(1) assert.is_not_nil(task._extra) assert.are.equal('hello', task._extra.custom_field) end) @@ -86,9 +85,8 @@ describe('store', function() describe('add', function() it('creates a task with incremented id', function() - store.load() - local t1 = store.add({ description = 'First' }) - local t2 = store.add({ description = 'Second' }) + local t1 = s:add({ description = 'First' }) + local t2 = s:add({ description = 'Second' }) assert.are.equal(1, t1.id) assert.are.equal(2, t2.id) assert.are.equal('pending', t1.status) @@ -96,60 +94,54 @@ describe('store', function() end) it('uses provided category', function() - store.load() - local t = store.add({ description = 'Test', category = 'Work' }) + local t = s:add({ description = 'Test', category = 'Work' }) assert.are.equal('Work', t.category) end) end) describe('update', function() it('updates fields and sets modified', function() - store.load() - local t = store.add({ description = 'Original' }) + local t = s:add({ description = 'Original' }) t.modified = '2025-01-01T00:00:00Z' - store.update(t.id, { description = 'Updated' }) - local updated = store.get(t.id) + s:update(t.id, { description = 'Updated' }) + local updated = s:get(t.id) assert.are.equal('Updated', updated.description) assert.is_not.equal('2025-01-01T00:00:00Z', updated.modified) end) it('sets end timestamp on completion', function() - store.load() - local t = store.add({ description = 'Test' }) + local t = s:add({ description = 'Test' }) assert.is_nil(t['end']) - store.update(t.id, { status = 'done' }) - local updated = store.get(t.id) + s:update(t.id, { status = 'done' }) + local updated = s:get(t.id) assert.is_not_nil(updated['end']) end) it('does not overwrite id or entry', function() - store.load() - local t = store.add({ description = 'Immutable fields' }) + local t = s:add({ description = 'Immutable fields' }) local original_id = t.id local original_entry = t.entry - store.update(t.id, { id = 999, entry = 'x' }) - local updated = store.get(original_id) + s:update(t.id, { id = 999, entry = 'x' }) + local updated = s:get(original_id) assert.are.equal(original_id, updated.id) assert.are.equal(original_entry, updated.entry) end) it('does not overwrite end on second completion', function() - store.load() - local t = store.add({ description = 'Complete twice' }) - store.update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' }) - local first_end = store.get(t.id)['end'] - store.update(t.id, { status = 'done' }) - local task = store.get(t.id) + local t = s:add({ description = 'Complete twice' }) + s:update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' }) + local first_end = s:get(t.id)['end'] + s:update(t.id, { status = 'done' }) + local task = s:get(t.id) assert.are.equal(first_end, task['end']) end) end) describe('delete', function() it('marks task as deleted', function() - store.load() - local t = store.add({ description = 'To delete' }) - store.delete(t.id) - local deleted = store.get(t.id) + local t = s:add({ description = 'To delete' }) + s:delete(t.id) + local deleted = s:get(t.id) assert.are.equal('deleted', deleted.status) assert.is_not_nil(deleted['end']) end) @@ -157,12 +149,10 @@ describe('store', function() describe('save and round-trip', function() it('persists and reloads correctly', function() - store.load() - store.add({ description = 'Persisted', category = 'Work', priority = 1 }) - store.save() - store.unload() - store.load() - local tasks = store.active_tasks() + s:add({ description = 'Persisted', category = 'Work', priority = 1 }) + s:save() + s:load() + local tasks = s:active_tasks() assert.are.equal(1, #tasks) assert.are.equal('Persisted', tasks[1].description) assert.are.equal('Work', tasks[1].category) @@ -170,7 +160,7 @@ describe('store', function() end) it('round-trips unknown fields', function() - local path = config.get().data_path + local path = tmpdir .. '/tasks.json' local f = io.open(path, 'w') f:write(vim.json.encode({ version = 1, @@ -187,22 +177,49 @@ describe('store', function() }, })) f:close() - store.load() - store.save() - store.unload() - store.load() - local task = store.get(1) + s:load() + s:save() + s:load() + local task = s:get(1) assert.are.equal('abc123', task._extra._gcal_event_id) 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('active_tasks', function() it('excludes deleted tasks', function() - store.load() - store.add({ description = 'Active' }) - local t2 = store.add({ description = 'To delete' }) - store.delete(t2.id) - local active = store.active_tasks() + s:add({ description = 'Active' }) + local t2 = s:add({ description = 'To delete' }) + s:delete(t2.id) + local active = s:active_tasks() assert.are.equal(1, #active) assert.are.equal('Active', active[1].description) end) @@ -210,27 +227,24 @@ describe('store', function() describe('snapshot', function() it('returns a table of tasks', function() - store.load() - store.add({ description = 'Snap one' }) - store.add({ description = 'Snap two' }) - local snap = store.snapshot() + s:add({ description = 'Snap one' }) + s:add({ description = 'Snap two' }) + local snap = s:snapshot() assert.are.equal(2, #snap) end) it('returns a copy that does not affect the store', function() - store.load() - local t = store.add({ description = 'Original' }) - local snap = store.snapshot() + local t = s:add({ description = 'Original' }) + local snap = s:snapshot() snap[1].description = 'Mutated' - local live = store.get(t.id) + local live = s:get(t.id) assert.are.equal('Original', live.description) end) it('excludes deleted tasks', function() - store.load() - local t = store.add({ description = 'Will be deleted' }) - store.delete(t.id) - local snap = store.snapshot() + local t = s:add({ description = 'Will be deleted' }) + s:delete(t.id) + local snap = s:snapshot() assert.are.equal(0, #snap) end) end) diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua new file mode 100644 index 0000000..93d3e2c --- /dev/null +++ b/spec/sync_spec.lua @@ -0,0 +1,120 @@ +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('Unknown Pending 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("gcal backend has 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 action', function() + local called = false + local gcal = require('pending.sync.gcal') + local orig_auth = gcal.auth + gcal.auth = function() + called = true + end + pending.command('gcal auth') + gcal.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 auth function', function() + local gcal = require('pending.sync.gcal') + assert.are.equal('function', type(gcal.auth)) + 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) diff --git a/spec/textobj_spec.lua b/spec/textobj_spec.lua new file mode 100644 index 0000000..1253f58 --- /dev/null +++ b/spec/textobj_spec.lua @@ -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) diff --git a/spec/views_spec.lua b/spec/views_spec.lua index 4d91e06..ede9de9 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -5,39 +5,38 @@ local store = require('pending.store') describe('views', function() local tmpdir + local s local views = require('pending.views') before_each(function() tmpdir = vim.fn.tempname() vim.fn.mkdir(tmpdir, 'p') - vim.g.pending = { data_path = tmpdir .. '/tasks.json' } config.reset() - store.unload() - store.load() + s = store.new(tmpdir .. '/tasks.json') + s:load() end) after_each(function() vim.fn.delete(tmpdir, 'rf') - vim.g.pending = nil config.reset() end) describe('category_view', function() it('groups tasks under their category header', function() - store.add({ description = 'Task A', category = 'Work' }) - store.add({ description = 'Task B', category = 'Work' }) - local lines, meta = views.category_view(store.active_tasks()) - assert.are.equal('## Work', lines[1]) + s:add({ description = 'Task A', category = 'Work' }) + s:add({ description = 'Task B', category = 'Work' }) + local lines, meta = views.category_view(s:active_tasks()) + assert.are.equal('# Work', lines[1]) assert.are.equal('header', meta[1].type) assert.is_true(lines[2]:find('Task A') ~= nil) assert.is_true(lines[3]:find('Task B') ~= nil) end) it('places pending tasks before done tasks within a category', function() - local t1 = store.add({ description = 'Done task', category = 'Work' }) - store.add({ description = 'Pending task', category = 'Work' }) - store.update(t1.id, { status = 'done' }) - local _, meta = views.category_view(store.active_tasks()) + local t1 = s:add({ description = 'Done task', category = 'Work' }) + s:add({ description = 'Pending task', category = 'Work' }) + s:update(t1.id, { status = 'done' }) + local _, meta = views.category_view(s:active_tasks()) local pending_row, done_row for i, m in ipairs(meta) do if m.type == 'task' and m.status == 'pending' then @@ -50,9 +49,9 @@ describe('views', function() end) it('sorts high-priority tasks before normal tasks within pending group', function() - store.add({ description = 'Normal', category = 'Work', priority = 0 }) - store.add({ description = 'High', category = 'Work', priority = 1 }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Normal', category = 'Work', priority = 0 }) + s:add({ description = 'High', category = 'Work', priority = 1 }) + local lines, meta = views.category_view(s:active_tasks()) local high_row, normal_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -68,11 +67,11 @@ describe('views', function() end) it('sorts high-priority tasks before normal tasks within done group', function() - local t1 = store.add({ description = 'Done Normal', category = 'Work', priority = 0 }) - local t2 = store.add({ description = 'Done High', category = 'Work', priority = 1 }) - store.update(t1.id, { status = 'done' }) - store.update(t2.id, { status = 'done' }) - local lines, meta = views.category_view(store.active_tasks()) + local t1 = s:add({ description = 'Done Normal', category = 'Work', priority = 0 }) + local t2 = s:add({ description = 'Done High', category = 'Work', priority = 1 }) + s:update(t1.id, { status = 'done' }) + s:update(t2.id, { status = 'done' }) + local lines, meta = views.category_view(s:active_tasks()) local high_row, normal_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -88,9 +87,9 @@ describe('views', function() end) it('gives each category its own header with blank lines between them', function() - store.add({ description = 'Task A', category = 'Work' }) - store.add({ description = 'Task B', category = 'Personal' }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Task A', category = 'Work' }) + s:add({ description = 'Task B', category = 'Personal' }) + local lines, meta = views.category_view(s:active_tasks()) local headers = {} local blank_found = false for i, m in ipairs(meta) do @@ -105,8 +104,8 @@ describe('views', function() end) it('formats task lines as /ID/ description', function() - store.add({ description = 'My task', category = 'Inbox' }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'My task', category = 'Inbox' }) + local lines, meta = views.category_view(s:active_tasks()) local task_line for i, m in ipairs(meta) do if m.type == 'task' then @@ -117,8 +116,8 @@ describe('views', function() end) it('formats priority task lines as /ID/- [!] description', function() - store.add({ description = 'Important', category = 'Inbox', priority = 1 }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Important', category = 'Inbox', priority = 1 }) + local lines, meta = views.category_view(s:active_tasks()) local task_line for i, m in ipairs(meta) do if m.type == 'task' then @@ -129,15 +128,15 @@ describe('views', function() end) it('sets LineMeta type=header for header lines with correct category', function() - store.add({ description = 'T', category = 'School' }) - local _, meta = views.category_view(store.active_tasks()) + s:add({ description = 'T', category = 'School' }) + local _, meta = views.category_view(s:active_tasks()) assert.are.equal('header', meta[1].type) assert.are.equal('School', meta[1].category) end) it('sets LineMeta type=task with correct id and status', function() - local t = store.add({ description = 'Do something', category = 'Inbox' }) - local _, meta = views.category_view(store.active_tasks()) + local t = s:add({ description = 'Do something', category = 'Inbox' }) + local _, meta = views.category_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' then @@ -150,9 +149,9 @@ describe('views', function() end) it('sets LineMeta type=blank for blank separator lines', function() - store.add({ description = 'A', category = 'Work' }) - store.add({ description = 'B', category = 'Home' }) - local _, meta = views.category_view(store.active_tasks()) + s:add({ description = 'A', category = 'Work' }) + s:add({ description = 'B', category = 'Home' }) + local _, meta = views.category_view(s:active_tasks()) local blank_meta for _, m in ipairs(meta) do if m.type == 'blank' then @@ -166,8 +165,8 @@ describe('views', function() it('marks overdue pending tasks with meta.overdue=true', function() local yesterday = os.date('%Y-%m-%d', os.time() - 86400) - local t = store.add({ description = 'Overdue task', category = 'Inbox', due = yesterday }) - local _, meta = views.category_view(store.active_tasks()) + local t = s:add({ description = 'Overdue task', category = 'Inbox', due = yesterday }) + local _, meta = views.category_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do 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() local tomorrow = os.date('%Y-%m-%d', os.time() + 86400) - local t = store.add({ description = 'Future task', category = 'Inbox', due = tomorrow }) - local _, meta = views.category_view(store.active_tasks()) + local t = s:add({ description = 'Future task', category = 'Inbox', due = tomorrow }) + local _, meta = views.category_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do 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() local yesterday = os.date('%Y-%m-%d', os.time() - 86400) - local t = store.add({ description = 'Done late', category = 'Inbox', due = yesterday }) - store.update(t.id, { status = 'done' }) - local _, meta = views.category_view(store.active_tasks()) + local t = s:add({ description = 'Done late', category = 'Inbox', due = yesterday }) + s:update(t.id, { status = 'done' }) + local _, meta = views.category_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -204,12 +203,36 @@ describe('views', function() assert.is_falsy(task_meta.overdue) 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() vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work', 'Inbox' } } config.reset() - store.add({ description = 'Inbox task', category = 'Inbox' }) - store.add({ description = 'Work task', category = 'Work' }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Inbox task', category = 'Inbox' }) + s:add({ description = 'Work task', category = 'Work' }) + local lines, meta = views.category_view(s:active_tasks()) local first_header, second_header for i, m in ipairs(meta) do if m.type == 'header' then @@ -220,47 +243,47 @@ describe('views', function() end end end - assert.are.equal('## Work', first_header) - assert.are.equal('## Inbox', second_header) + assert.are.equal('# Work', first_header) + assert.are.equal('# Inbox', second_header) end) it('appends categories not in category_order after ordered ones', function() vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work' } } config.reset() - store.add({ description = 'Errand', category = 'Errands' }) - store.add({ description = 'Work task', category = 'Work' }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Errand', category = 'Errands' }) + s:add({ description = 'Work task', category = 'Work' }) + local lines, meta = views.category_view(s:active_tasks()) local headers = {} for i, m in ipairs(meta) do if m.type == 'header' then table.insert(headers, lines[i]) end end - assert.are.equal('## Work', headers[1]) - assert.are.equal('## Errands', headers[2]) + assert.are.equal('# Work', headers[1]) + assert.are.equal('# Errands', headers[2]) end) it('preserves insertion order when category_order is empty', function() - store.add({ description = 'Alpha task', category = 'Alpha' }) - store.add({ description = 'Beta task', category = 'Beta' }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Alpha task', category = 'Alpha' }) + s:add({ description = 'Beta task', category = 'Beta' }) + local lines, meta = views.category_view(s:active_tasks()) local headers = {} for i, m in ipairs(meta) do if m.type == 'header' then table.insert(headers, lines[i]) end end - assert.are.equal('## Alpha', headers[1]) - assert.are.equal('## Beta', headers[2]) + assert.are.equal('# Alpha', headers[1]) + assert.are.equal('# Beta', headers[2]) end) end) describe('priority_view', function() it('places all pending tasks before done tasks', function() - local t1 = store.add({ description = 'Done A', category = 'Work' }) - store.add({ description = 'Pending B', category = 'Work' }) - store.update(t1.id, { status = 'done' }) - local _, meta = views.priority_view(store.active_tasks()) + local t1 = s:add({ description = 'Done A', category = 'Work' }) + s:add({ description = 'Pending B', category = 'Work' }) + s:update(t1.id, { status = 'done' }) + local _, meta = views.priority_view(s:active_tasks()) local last_pending_row, first_done_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -275,9 +298,9 @@ describe('views', function() end) it('sorts pending tasks by priority desc within pending group', function() - store.add({ description = 'Low', category = 'Work', priority = 0 }) - store.add({ description = 'High', category = 'Work', priority = 1 }) - local lines, meta = views.priority_view(store.active_tasks()) + s:add({ description = 'Low', category = 'Work', priority = 0 }) + s:add({ description = 'High', category = 'Work', priority = 1 }) + local lines, meta = views.priority_view(s:active_tasks()) local high_row, low_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -292,9 +315,9 @@ describe('views', function() end) it('sorts pending tasks with due dates before those without', function() - store.add({ description = 'No due', category = 'Work' }) - store.add({ description = 'Has due', category = 'Work', due = '2099-12-31' }) - local lines, meta = views.priority_view(store.active_tasks()) + s:add({ description = 'No due', category = 'Work' }) + s:add({ description = 'Has due', category = 'Work', due = '2099-12-31' }) + local lines, meta = views.priority_view(s:active_tasks()) local due_row, nodue_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -309,9 +332,9 @@ describe('views', function() end) it('sorts pending tasks with earlier due dates before later due dates', function() - store.add({ description = 'Later', category = 'Work', due = '2099-12-31' }) - store.add({ description = 'Earlier', category = 'Work', due = '2050-01-01' }) - local lines, meta = views.priority_view(store.active_tasks()) + s:add({ description = 'Later', category = 'Work', due = '2099-12-31' }) + s:add({ description = 'Earlier', category = 'Work', due = '2050-01-01' }) + local lines, meta = views.priority_view(s:active_tasks()) local earlier_row, later_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -326,15 +349,15 @@ describe('views', function() end) it('formats task lines as /ID/- [ ] description', function() - store.add({ description = 'My task', category = 'Inbox' }) - local lines, _ = views.priority_view(store.active_tasks()) + s:add({ description = 'My task', category = 'Inbox' }) + local lines, _ = views.priority_view(s:active_tasks()) assert.are.equal('/1/- [ ] My task', lines[1]) end) it('sets show_category=true for all task meta entries', function() - store.add({ description = 'T1', category = 'Work' }) - store.add({ description = 'T2', category = 'Personal' }) - local _, meta = views.priority_view(store.active_tasks()) + s:add({ description = 'T1', category = 'Work' }) + s:add({ description = 'T2', category = 'Personal' }) + local _, meta = views.priority_view(s:active_tasks()) for _, m in ipairs(meta) do if m.type == 'task' then assert.is_true(m.show_category == true) @@ -343,9 +366,9 @@ describe('views', function() end) it('sets meta.category correctly for each task', function() - store.add({ description = 'Work task', category = 'Work' }) - store.add({ description = 'Home task', category = 'Home' }) - local lines, meta = views.priority_view(store.active_tasks()) + s:add({ description = 'Work task', category = 'Work' }) + s:add({ description = 'Home task', category = 'Home' }) + local lines, meta = views.priority_view(s:active_tasks()) local categories = {} for i, m in ipairs(meta) do if m.type == 'task' then @@ -362,8 +385,8 @@ describe('views', function() it('marks overdue pending tasks with meta.overdue=true', function() local yesterday = os.date('%Y-%m-%d', os.time() - 86400) - local t = store.add({ description = 'Overdue', category = 'Inbox', due = yesterday }) - local _, meta = views.priority_view(store.active_tasks()) + local t = s:add({ description = 'Overdue', category = 'Inbox', due = yesterday }) + local _, meta = views.priority_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do 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() local tomorrow = os.date('%Y-%m-%d', os.time() + 86400) - local t = store.add({ description = 'Future', category = 'Inbox', due = tomorrow }) - local _, meta = views.priority_view(store.active_tasks()) + local t = s:add({ description = 'Future', category = 'Inbox', due = tomorrow }) + local _, meta = views.priority_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do 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() local yesterday = os.date('%Y-%m-%d', os.time() - 86400) - local t = store.add({ description = 'Done late', category = 'Inbox', due = yesterday }) - store.update(t.id, { status = 'done' }) - local _, meta = views.priority_view(store.active_tasks()) + local t = s:add({ description = 'Done late', category = 'Inbox', due = yesterday }) + s:update(t.id, { status = 'done' }) + local _, meta = views.priority_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -399,5 +422,29 @@ describe('views', function() end assert.is_falsy(task_meta.overdue) 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)