diff --git a/.gitignore b/.gitignore index 7cdfb66..93ac2c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ doc/tags *.log -minimal_init.lua .*cache* CLAUDE.md diff --git a/.luarc.json b/.luarc.json index c8eaaf9..23646d3 100644 --- a/.luarc.json +++ b/.luarc.json @@ -2,14 +2,7 @@ "runtime.version": "LuaJIT", "runtime.path": ["lua/?.lua", "lua/?/init.lua"], "diagnostics.globals": ["vim", "jit"], - "diagnostics.libraryFiles": "Disable", - "workspace.library": [ - "$VIMRUNTIME/lua", - "${3rd}/luv/library", - "${3rd}/busted/library", - "${3rd}/luassert/library" - ], + "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], "workspace.checkThirdParty": false, - "workspace.ignoreDir": [".direnv"], "completion.callSnippet": "Replace" } diff --git a/README.md b/README.md index 3448941..df7f3dd 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,13 @@ # pending.nvim -**Edit tasks like text.** +Edit tasks like text. `:w` saves them. -Oil-like task management for todos in Neovim, inspired by -[oil.nvim](https://github.com/stevearc/oil.nvim) and -[vim-fugitive](https://github.com/tpope/vim-fugitive) - -https://github.com/user-attachments/assets/f3898ecb-ec95-43fe-a71f-9c9f49628ba9 + ## Requirements - Neovim 0.10+ -- (Optionally) `curl` for Google Calendar and Google Task sync +- (Optionally) `curl` and `openssl` for Google Calendar and Google Task sync ## Installation diff --git a/doc/pending.txt b/doc/pending.txt index 739ca88..4eb8e40 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -30,52 +30,21 @@ concealed tokens and are never visible during editing. Features: ~ - Oil-style buffer editing: standard Vim motions for all task operations -- 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) +- 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) - Quick-add from the command line with `:Pending add` - Quickfix list of overdue/due-today tasks via `:Pending due` -- Configurable category folds (`zc`/`zo`) with custom foldtext -- Omnifunc completion for `cat:`, `due:`, and `rec:` tokens (``) +- Foldable category sections (`zc`/`zo`) in category view - 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. Google Authentication ......................... |pending-google-auth| - 21. S3 Sync ................................................... |pending-s3| - 22. Data Format .............................................. |pending-data| - 23. Health Check ........................................... |pending-health| ============================================================================== REQUIREMENTS *pending-requirements* - Neovim 0.10+ - No external dependencies for local use -- `curl` is required for the `gcal` and `gtasks` sync backends +- `curl` and `openssl` are required for Google Calendar sync ============================================================================== INSTALL *pending-install* @@ -117,6 +86,39 @@ 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* @@ -133,32 +135,15 @@ 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 - :Pending add Buy milk due:fri +!! < - Trailing `+!`, `+!!`, or `+!!!` tokens set the priority level (capped - at `max_priority`). If the buffer is currently open it is re-rendered - after the add. + If the buffer is currently open it is re-rendered after the add. *:Pending-archive* -:Pending archive [{duration}] +:Pending archive [{days}] Permanently remove done and deleted tasks whose completion timestamp is - older than {duration}. {duration} defaults to 30 days if not provided. - - Supported duration formats: - `Nd` N days (e.g. `7d`) - `Nw` N weeks (e.g. `3w` → 21 days) - `Nm` N months (e.g. `2m` → 60 days, approximated as N×30) - `N` bare integer, treated as days (backwards-compatible) - - A confirmation prompt is shown before any tasks are removed. If no - tasks match the cutoff, a message is displayed and no prompt appears. ->vim - :Pending archive " 30-day default, with confirmation - :Pending archive 7d " tasks completed more than 7 days ago - :Pending archive 3w " tasks completed more than 21 days ago - :Pending archive 2m " tasks completed more than 60 days ago - :Pending archive 30 " bare integer, same as 30d + older than {days} days. {days} defaults to 30 if not provided. >vim + :Pending archive " remove tasks completed more than 30 days ago + :Pending archive 7 " remove tasks completed more than 7 days ago < *:Pending-due* @@ -166,112 +151,17 @@ 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-auth* -:Pending auth - Authorize pending.nvim to access Google services (Tasks and Calendar). - Prompts with |vim.ui.select| to choose gtasks, gcal, or both — all - options run the same combined OAuth flow and produce a single shared - token file. If no credentials are configured, the setup wizard runs - first to collect a client ID and secret. - See |pending-google-auth| for full details. - - *: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. - - Examples: >vim - :Pending gtasks sync " push then pull - :Pending gtasks push " push local → Google Tasks - :Pending gtasks pull " pull Google Tasks → local -< - - 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. - - Examples: >vim - :Pending gcal push " push to Google Calendar -< - - 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. {id} is the numeric task ID. When - {id} is omitted and the task buffer is open, the task under the cursor - is used. This makes `:Pending edit +!` work without knowing the ID. - One or more operations follow: >vim - :Pending edit 5 due:tomorrow cat:Work +! - :Pending edit 5 -due -cat -rec - :Pending edit +!! -< - Operations: ~ - `due:` Set due date (accepts all |pending-dates| vocabulary). - `cat:` Set category. - `rec:` Set recurrence (prefix `!` for completion-based). - `+!` Set priority to 1. - `+!!` Set priority to 2. - `+!!!` Set priority to 3 (capped at `max_priority`). - `-!` 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-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-undo* :Pending undo Undo the last `:w` save, restoring the task store to its previous state. - Equivalent to the `gz` buffer-local key (see |pending-mappings|). Up to 20 - 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. + Equivalent to the `U` buffer-local key (see |pending-mappings|). Up to 20 + levels of undo are retained per session. ============================================================================== MAPPINGS *pending-mappings* @@ -279,96 +169,34 @@ 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 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: ~ +Buffer-local keys: ~ Key Action ~ ------- ------------------------------------------------ - `q` Close the task buffer (`close`) - `` Toggle complete / uncomplete (`toggle`) - `g!` Cycle priority: 0→1→2→3→0 (`priority`) - `gd` Prompt for a due date (`date`) - `gc` Select a category from existing categories (`category`) - `gr` Prompt for a recurrence pattern (`recur`) - `gw` Toggle work-in-progress status (`wip`) - `gb` Toggle blocked status (`blocked`) - `gf` Prompt for filter predicates (`filter`) - `` Switch between category / queue view (`view`) - `gz` 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`) - `` Increment priority (clamped at `max_priority`) (`priority_up`) - `` Decrement priority (clamped at 0) (`priority_down`) - `J` Move task down within its category (`move_down`) - `K` Move task up within its category (`move_up`) - `zc` Fold the current category section (requires `folding`) - `zo` Unfold the current category section (requires `folding`) + `` 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 + `zc` Fold the current category section (category view only) + `zo` Unfold the current category section (category view only) -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. - -Deprecated keys: ~ *pending-deprecated-keys* -The following keys were renamed to avoid shadowing Vim builtins. The old -keys still work but emit a deprecation warning and will be removed in a -future release: - - Old New Action ~ - ------- ------- ------------------------------------------------ - `!` `g!` Toggle the priority flag - `D` `gd` Prompt for a due date - `F` `gf` Prompt for filter predicates - `U` `gz` Undo the last `:w` save - -To silence warnings, set the new keys explicitly in your config or set the -old keys to `false`: >lua - vim.g.pending = { keymaps = { priority = 'g!' } } -< +`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. *(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. *(pending-priority)* (pending-priority) - Cycle the priority level for the task under the cursor (0→1→2→3→0). - The maximum level is controlled by `max_priority` in |pending-config|. + Toggle the priority flag for the task under the cursor. *(pending-date)* (pending-date) @@ -378,96 +206,6 @@ old keys to `false`: >lua (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-category)* -(pending-category) - Select a category for the task under the cursor via |vim.ui.select|. - - *(pending-recur)* -(pending-recur) - Prompt for a recurrence pattern for the task under the cursor. - Prefix with `!` for completion mode (e.g. `!weekly`). Empty input - removes recurrence. - - *(pending-move-down)* -(pending-move-down) - Swap the task under the cursor with the one below it. In category - view, movement is limited to tasks within the same category. - - *(pending-move-up)* -(pending-move-up) - Swap the task under the cursor with the one above it. - - *(pending-wip)* -(pending-wip) - Toggle work-in-progress status for the task under the cursor. - If the task is already `wip`, reverts to `pending`. - - *(pending-blocked)* -(pending-blocked) - Toggle blocked status for the task under the cursor. - If the task is already `blocked`, reverts to `pending`. - - *(pending-priority-up)* -(pending-priority-up) - Increment the priority level for the task under the cursor, clamped - at `max_priority`. Default key: ``. - - *(pending-priority-down)* -(pending-priority-down) - Decrement the priority level for the task under the cursor, clamped - at 0. Default key: ``. - - *(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)') @@ -482,195 +220,16 @@ Category view (default): ~ *pending-view-category* Tasks are grouped under their category header. Categories appear in the order tasks were added unless `category_order` is set (see |pending-config|). Blank lines separate categories. Within each category, - tasks are sorted by status (wip → pending → blocked → done), then by - priority, then by insertion order. Category sections are foldable with - `zc` and `zo`. + pending tasks appear before done tasks. Priority tasks (`!`) are sorted + first within each group. Category sections are foldable with `zc` and + `zo`. -Queue view: ~ *pending-view-queue* - A flat list of all tasks sorted by status (wip → pending → blocked → - done), then by priority, then by due date (tasks without a due date sort - last), then by internal order. Category names are shown as right-aligned virtual +Priority view: ~ *pending-view-priority* + 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. 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). - - `wip` Show only tasks with status `wip` (work in progress). - - `blocked` Show only tasks with status `blocked`. - - `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`) - -Custom formats: ~ *pending-dates-custom* -Additional input formats can be configured via `input_date_formats` in -|pending-config|. They are tried in order after all built-in keywords fail. -See |pending-input-formats| for supported specifiers and examples. - -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). + across categories. ============================================================================== CONFIGURATION *pending-config* @@ -679,49 +238,14 @@ Configuration is done via `vim.g.pending`. Set this before the plugin loads: >lua vim.g.pending = { data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', - default_category = 'Todo', + default_view = 'category', + default_category = 'Inbox', date_format = '%b %d', date_syntax = 'due', - recur_syntax = 'rec', - someday_date = '9999-12-30', - max_priority = 3, - view = { - default = 'category', - eol_format = '%c %r %d', - category = { - order = {}, - folding = true, - }, - queue = {}, - }, - keymaps = { - close = 'q', - toggle = '', - view = '', - priority = 'g!', - date = 'gd', - undo = 'gz', - filter = 'gf', - 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', - category = 'gc', - recur = 'gr', - move_down = 'J', - move_up = 'K', - wip = 'gw', - blocked = 'gb', - }, - sync = { - gcal = {}, - gtasks = {}, + category_order = {}, + gcal = { + calendar = 'Tasks', + credentials_path = '/path/to/client_secret.json', }, } < @@ -731,13 +255,15 @@ All fields are optional. Unset fields use the defaults shown above. *pending.Config* Fields: ~ {data_path} (string) - Path to the global JSON file where tasks are stored. + Path to the 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_category} (string, default: 'Todo') + {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') Category assigned to new tasks when no `cat:` token is present and no `Category: ` prefix is used with `:Pending add`. @@ -747,160 +273,75 @@ Fields: ~ virtual text in the buffer. Examples: `'%Y-%m-%d'` for ISO dates, `'%d %b'` for day-first. - {input_date_formats} (string[], default: {}) *pending-input-formats* - List of strftime-like format strings tried in order - when parsing a `due:` token that does not match the - built-in keywords or ISO `YYYY-MM-DD` format. - Specifiers supported: `%Y` (4-digit year), `%y` - (2-digit year, 00–69 → 2000s, 70–99 → 1900s), `%m` - (numeric month), `%d` / `%e` (day), `%b` / `%B` - (abbreviated or full month name, case-insensitive). - When no year specifier is present the current year is - used, advancing to next year if the date has already - passed. Examples: >lua - input_date_formats = { - '%m/%d/%Y', -- 03/15/2026 - '%d-%b-%Y', -- 15-Mar-2026 - '%m/%d', -- 03/15 (year inferred) - } -< {date_syntax} (string, default: 'due') The token name for inline due-date metadata. Change 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`. + {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. - {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. - - {view} (table) *pending.ViewConfig* - View rendering configuration. Groups all settings - that affect how the buffer displays tasks. - - {default} ('category'|'priority', default: 'category') - The view to use when the buffer is opened - for the first time in a session. - - {eol_format} (string, default: '%c %r %d') - Format string for end-of-line virtual text. - Specifiers: - `%c` category icon + name (`PendingHeader`) - `%r` recurrence icon + pattern (`PendingRecur`) - `%d` due icon + date (`PendingDue`/`PendingOverdue`) - Literal text between specifiers acts as a - separator. Absent fields and surrounding - literals are collapsed automatically. `%c` - only renders in priority view. - - {category} (table) *pending.CategoryViewConfig* - Category view settings. - - {order} (string[], default: {}) - Ordered list of category names. Categories - in this list appear in the given order; - others are appended after. - - {folding} (boolean|table, default: true) - *pending.FoldingConfig* - Controls category-level folds. `true` - enables with default foldtext `'%c (%n - tasks)'`. `false` disables entirely. A - table may contain: - {foldtext} (string|false) Format string - with `%c` (category) and `%n` (count). - `false` uses Vim's built-in foldtext. - Folds only apply to category view. - - {queue} (table) *pending.QueueViewConfig* - Queue (priority) view settings. - - Examples: >lua - vim.g.pending = { - view = { - default = 'priority', - eol_format = '%d | %r', - category = { - order = { 'Work', 'Personal' }, - folding = { foldtext = '%c: %n items' }, - }, - }, - } -< - - {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. - - {max_priority} (integer, default: 3) - Maximum priority level. The `g!` keymap cycles - through `0 → 1 → … → max_priority → 0`. Priority - levels map to highlight groups: `PendingPriority` - (1), `PendingPriority2` (2), `PendingPriority3` - (3+). `:Pending edit +!!` and `:Pending add +!!!` - accept multi-bang syntax capped at this value. - Set to `1` for the old binary on/off behavior. - - {debug} (boolean, default: false) - Enable diagnostic logging. When `true`, textobj - motions, mapping registration, and cursor jumps - emit messages at `vim.log.levels.DEBUG`. Use - |:messages| to inspect the output. Useful for - diagnosing keymap conflicts (e.g. `]t` colliding - with Neovim defaults) or motion misbehavior. - Example: >lua - vim.g.pending = { debug = true } -< - - {sync} (table, default: {}) *pending.SyncConfig* - Sync backend configuration. Each key is a backend - name and the value is the backend-specific config - table. Built-in backends: `gcal`, `gtasks`. Both - ship bundled OAuth credentials so no setup is - needed beyond `:Pending auth`. - - {icons} (table) *pending.Icons* - Icon characters displayed in the buffer. The - {pending}, {done}, {priority}, {wip}, and - {blocked} 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: '!' - {wip} Work-in-progress character. Default: '>' - {blocked} Blocked task character. Default: '=' - {due} Due date prefix. Default: '.' - {recur} Recurrence prefix. Default: '~' - {category} Category prefix. Default: '#' + {gcal} (table, default: nil) + Google Calendar sync configuration. See + |pending.GcalConfig|. Omit this field entirely to + disable Google Calendar sync. ============================================================================== -STORE RESOLUTION *pending-store-resolution* +GOOGLE CALENDAR *pending-gcal* -When pending.nvim opens the task buffer it resolves which store file to use: +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. -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 +Configuration: >lua + vim.g.pending = { + gcal = { + calendar = 'Tasks', + credentials_path = '/path/to/client_secret.json', + }, + } < -The `:checkhealth pending` report shows which store file is currently active. + *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. + + {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. + +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. + +`: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. + +A summary notification is shown after sync: `created: N, updated: N, +deleted: N`. ============================================================================== HIGHLIGHT GROUPS *pending-highlights* @@ -926,34 +367,8 @@ PendingOverdue Applied to the due date virtual text of overdue tasks. PendingDone Applied to the text of completed tasks. Default: links to `Comment`. - *PendingWip* -PendingWip Applied to the checkbox icon of work-in-progress tasks. - Default: links to `DiagnosticInfo`. - - *PendingBlocked* -PendingBlocked Applied to the checkbox icon and text of blocked tasks. - Default: links to `DiagnosticError`. - *PendingPriority* -PendingPriority Applied to the checkbox icon of priority 1 tasks. - Default: links to `DiagnosticWarn`. - - *PendingPriority2* -PendingPriority2 Applied to the checkbox icon of priority 2 tasks. - Default: links to `DiagnosticError`. - - *PendingPriority3* -PendingPriority3 Applied to the checkbox icon of priority 3+ tasks. - Default: links to `DiagnosticError`. - - *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. +PendingPriority Applied to the `! ` priority marker on priority tasks. Default: links to `DiagnosticWarn`. To override a group in your colorscheme or config: >lua @@ -961,425 +376,25 @@ To override a group in your colorscheme or config: >lua < ============================================================================== -LUA API *pending-api* +HEALTH CHECK *pending-health* -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, - }) +Run |:checkhealth| pending to verify your setup: >vim + :checkhealth pending < -============================================================================== -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`. Backends -are auto-discovered at runtime — any module that exports a `name` field is -registered automatically. No hardcoded list or manual registration step is -required. Adding a backend is as simple as creating a new file. - -Each backend is exposed as a top-level `:Pending` subcommand: >vim - :Pending gtasks {action} - :Pending gcal {action} - :Pending s3 {action} -< - -Each module returns a table conforming to the backend interface: >lua - - ---@class pending.SyncBackend - ---@field name string - ---@field auth? fun(args?: string): nil - ---@field auth_complete? fun(arg_lead: string): string[] - ---@field push? fun(): nil - ---@field pull? fun(): nil - ---@field sync? fun(): nil - ---@field health? fun(): nil -< - -Required fields: ~ - {name} Backend identifier (matches the filename). - -Optional fields: ~ - {auth} Per-backend authentication. Called by `:Pending auth `. - Receives an optional sub-action string (e.g. `"clear"`). - {auth_complete} Returns valid sub-action completions for tab completion - (e.g. `{ "clear", "reset" }`). - {push} Push-only action. Called by `:Pending push`. - {pull} Pull-only action. Called by `:Pending pull`. - {sync} Main sync action. Called by `:Pending sync`. - {health} Called by `:checkhealth pending` to report backend-specific - diagnostics (e.g. checking for external tools). - -Modules without a `name` field (e.g. `oauth.lua`, `util.lua`) are ignored -by discovery and do not appear as backends. - -Shared utilities for backend authors are provided by `sync/util.lua`: - `util.async(fn)` Coroutine wrapper for async operations. - `util.system(args)` Coroutine-aware `vim.system` wrapper. - `util.with_guard(name, fn)` Concurrency guard — prevents overlapping - sync operations. Clears on return or error. - `util.finish(s)` Persist store, recompute counts, re-render - the buffer. Typical sync epilogue. - `util.fmt_counts(parts)` Format `{ {n, label}, ... }` into a - human-readable summary string. - -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 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: ~ -See |pending-google-auth|. Tokens are shared with the gtasks backend and -stored at `stdpath('data')/pending/google_tokens.json`. - -`: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 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: ~ -See |pending-google-auth|. Tokens are shared with the gcal backend and -stored at `stdpath('data')/pending/google_tokens.json`. - -`: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). - -============================================================================== -GOOGLE AUTHENTICATION *pending-google-auth* - -Both the gcal and gtasks backends share a single OAuth client with combined -scopes (`tasks` + `calendar`). One authorization flow covers both services -and produces one token file. - -:Pending auth ~ -`:Pending auth` dispatches to per-backend `auth()` methods. When called -without arguments, if multiple backends have auth methods, a -|vim.ui.select| prompt lets you choose. With an explicit backend name, -the call goes directly: >vim - :Pending auth gcal - :Pending auth gtasks - :Pending auth gcal clear - :Pending auth gtasks reset -< - -Sub-actions are backend-specific. Google backends support `clear` (remove -tokens) and `reset` (remove tokens and credentials). If no real credentials -are configured (i.e. bundled placeholders are in use), the setup wizard runs -first to collect a client ID and client secret before opening the browser. - -OAuth flow: ~ -A PKCE (Proof Key for Code Exchange) flow is used: -1. A random 64-character `code_verifier` is generated. -2. Its SHA-256 hash is base64url-encoded as the `code_challenge`. -3. The Google authorization URL is opened in the browser via |vim.ui.open()|. -4. A temporary TCP server on port 18392 waits up to 120 seconds for the - OAuth redirect. -5. The authorization code is exchanged for tokens via `curl`. -6. The refresh token is written to - `stdpath('data')/pending/google_tokens.json` with mode `600`. -7. Subsequent syncs refresh the access token automatically when it is about - to expire (within 60 seconds of the `expires_in` window). - -Credential resolution: ~ -Credentials are resolved in order for the `google` config key: -1. `client_id` + `client_secret` under `sync.google` (highest priority). -2. JSON file at `sync.google.credentials_path` or the default path - `stdpath('data')/pending/google_credentials.json`. -3. Bundled placeholder credentials (always available; trigger setup wizard). - -The `installed` wrapper format from the Google Cloud Console is accepted. - -============================================================================== -S3 SYNC *pending-s3* - -pending.nvim can sync the task store to an S3 bucket. This enables -whole-store synchronization between machines via the AWS CLI. - -Configuration: >lua - vim.g.pending = { - sync = { - s3 = { - bucket = 'my-tasks-bucket', - key = 'pending.json', -- optional, default "pending.json" - profile = 'personal', -- optional AWS CLI profile - region = 'us-east-1', -- optional region override - }, - }, - } -< - - *pending.S3Config* -Fields: ~ - {bucket} (string, required) - S3 bucket name. - - {key} (string, optional, default `"pending.json"`) - S3 object key (path within the bucket). - - {profile} (string, optional) - AWS CLI profile name. Maps to `--profile`. - - {region} (string, optional) - AWS region override. Maps to `--region`. - -Credential resolution: ~ -Delegates entirely to the AWS CLI credential chain (environment variables, -~/.aws/credentials, IAM roles, SSO, etc.). No credentials are stored by -pending.nvim. - -Auth flow: ~ -`:Pending auth s3` runs `aws sts get-caller-identity` to verify credentials. -If the profile uses SSO and the session has expired, it automatically runs -`aws sso login`. Sub-action `profile` prompts for a profile name. - -`:Pending s3 push` behavior: ~ -Assigns a `_s3_sync_id` (UUID) to each task that lacks one, serializes the -store to a temp file, and uploads it to S3 via `aws s3 cp`. - -`:Pending s3 pull` behavior: ~ -Downloads the remote store from S3, then merges per-task by `_s3_sync_id`: -- Remote task with a matching local task: the version with the newer - `modified` timestamp wins. -- Remote task with no local match: added to the local store. -- Local tasks not present in the remote: kept (local-only tasks are never - deleted by pull). - -`:Pending s3 sync` behavior: ~ -Pulls first (merge), then pushes the merged result. +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) ============================================================================== DATA FORMAT *pending-data* -Tasks are stored as JSON at the active store path (see -|pending-store-resolution|). The file is safe to edit by hand and +Tasks are stored as JSON at `data_path`. 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. @@ -1395,14 +410,10 @@ Schema: > Task fields: ~ {id} (integer) Unique, auto-incrementing task identifier. {description} (string) Task text as shown in the buffer. - {status} (string) `'pending'`, `'wip'`, `'blocked'`, `'done'`, - or `'deleted'`. + {status} (string) `'pending'`, `'done'`, or `'deleted'`. {category} (string) Category name. Defaults to `default_category`. - {priority} (integer) Priority level: `0` (none), `1`–`3` (or up to - `max_priority`). Higher values sort first. + {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. @@ -1410,8 +421,7 @@ 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 Google Tasks IDs (`_gtasks_task_id`, -`_gtasks_list_id`), and allows third-party tooling to annotate tasks without +(`_gcal_event_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 @@ -1419,10 +429,4 @@ version the plugin supports, loading is aborted with an error message asking you to update the plugin. ============================================================================== -HEALTH CHECK *pending-health* - -Run |:checkhealth| pending to verify your setup: >vim - :checkhealth pending -< - -============================================================================== + vim:tw=78:ts=8:ft=help:norl: diff --git a/flake.nix b/flake.nix index f895154..da16aea 100644 --- a/flake.nix +++ b/flake.nix @@ -13,12 +13,9 @@ ... }: 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 250ed8e..d11254b 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -1,37 +1,21 @@ local config = require('pending.config') -local log = require('pending.log') +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? local task_winid = nil -local ns_eol = vim.api.nvim_create_namespace('pending_eol') -local ns_inline = vim.api.nvim_create_namespace('pending_inline') +local task_ns = vim.api.nvim_create_namespace('pending') ---@type 'category'|'priority'|nil local current_view = nil ---@type pending.LineMeta[] local _meta = {} ---@type table> local _fold_state = {} ----@type boolean -local _initial_fold_loaded = false ----@type string[] -local _filter_predicates = {} ----@type table -local _hidden_ids = {} ----@type table -local _dirty_rows = {} ----@type boolean -local _on_bytes_active = false ----@type boolean -local _rendering = false ---@return pending.LineMeta[] function M.meta() @@ -53,275 +37,12 @@ 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 ----@param winid integer ----@return nil -function M.update_winid(winid) - task_winid = winid -end - ----@param b? integer ----@return nil -function M.clear_marks(b) - local bufnr = b or task_bufnr - vim.api.nvim_buf_clear_namespace(bufnr, ns_eol, 0, -1) - vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, 0, -1) -end - ----@param b integer ----@param row integer ----@return nil -function M.clear_inline_row(b, row) - vim.api.nvim_buf_clear_namespace(b, ns_inline, row - 1, row) -end - ----@return table -function M.dirty_rows() - return _dirty_rows -end - ----@return nil -function M.clear_dirty_rows() - _dirty_rows = {} -end - ----@param bufnr integer ----@param row integer ----@param m pending.LineMeta ----@param icons table -local function apply_inline_row(bufnr, row, m, icons) - if m.type == 'filter' then - local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' - vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { - end_col = #line, - hl_group = 'PendingFilter', - }) - elseif m.type == 'task' then - if m.status == 'done' then - local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' - local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) or 0 - vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, { - end_col = #line, - hl_group = 'PendingDone', - }) - elseif m.status == 'blocked' then - local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' - local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) or 0 - vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, { - end_col = #line, - hl_group = 'PendingBlocked', - }) - 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.status == 'wip' then - icon, icon_hl = icons.wip or '>', 'PendingWip' - elseif m.status == 'blocked' then - icon, icon_hl = icons.blocked or '=', 'PendingBlocked' - elseif m.priority and m.priority >= 3 then - icon, icon_hl = icons.priority, 'PendingPriority3' - elseif m.priority and m.priority == 2 then - icon, icon_hl = icons.priority, 'PendingPriority2' - elseif m.priority and m.priority > 0 then - icon, icon_hl = icons.priority, 'PendingPriority' - else - icon, icon_hl = icons.pending, 'Normal' - end - vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, bracket_col, { - virt_text = { { '[' .. icon .. ']', icon_hl } }, - virt_text_pos = 'overlay', - priority = 100, - }) - elseif m.type == 'header' then - local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' - vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { - end_col = #line, - hl_group = 'PendingHeader', - }) - vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { - virt_text = { { icons.category .. ' ', 'PendingHeader' } }, - virt_text_pos = 'overlay', - priority = 100, - }) - end -end - ----@param line string ----@return string? -local function infer_status(line) - local ch = line:match('^/%d+/- %[(.)%]') or line:match('^- %[(.)%]') - if not ch then - return nil - end - if ch == 'x' then - return 'done' - elseif ch == '>' then - return 'wip' - elseif ch == '=' then - return 'blocked' - end - return 'pending' -end - ----@param bufnr integer ----@return nil -function M.reapply_dirty_inline(bufnr) - if not next(_dirty_rows) then - return - end - local icons = config.get().icons - for row in pairs(_dirty_rows) do - local m = _meta[row] - if m and m.type == 'task' then - local line = vim.api.nvim_buf_get_lines(bufnr, row - 1, row, false)[1] or '' - m.status = infer_status(line) or m.status - end - if m then - vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, row - 1, row) - apply_inline_row(bufnr, row - 1, m, icons) - end - end - _dirty_rows = {} -end - ----@param bufnr integer ----@return nil -function M.attach_bytes(bufnr) - if _on_bytes_active then - return - end - _on_bytes_active = true - vim.api.nvim_buf_attach(bufnr, false, { - on_bytes = function(_, buf, _, start_row, _, _, old_end_row, _, _, new_end_row, _, _) - if buf ~= task_bufnr then - _on_bytes_active = false - return true - end - if _rendering then - return - end - local delta = new_end_row - old_end_row - if delta > 0 then - for _ = 1, delta do - table.insert(_meta, start_row + 2, { type = 'task' }) - end - elseif delta < 0 then - for _ = 1, -delta do - if _meta[start_row + 2] then - table.remove(_meta, start_row + 2) - end - end - end - for r = start_row + 1, start_row + 1 + math.max(0, new_end_row) do - _dirty_rows[r] = true - end - end, - }) -end - ----@return nil -function M.persist_folds() - log.debug( - ('persist_folds: view=%s store=%s'):format(tostring(current_view), tostring(_store ~= nil)) - ) - if current_view ~= 'category' or not _store then - log.debug('persist_folds: early return (view or store)') - return - end - local bufnr = task_bufnr - if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then - log.debug('persist_folds: early return (no valid bufnr)') - return - end - local folded = {} - local seen = {} - local wins = vim.fn.win_findbuf(bufnr) - log.debug( - ('persist_folds: checking %d windows for bufnr=%d, meta has %d entries'):format( - #wins, - bufnr, - #_meta - ) - ) - for _, winid in ipairs(wins) do - if vim.api.nvim_win_is_valid(winid) then - vim.api.nvim_win_call(winid, function() - for lnum, m in ipairs(_meta) do - if m.type == 'header' and m.category and not seen[m.category] then - local closed = vim.fn.foldclosed(lnum) - log.debug( - ('persist_folds: win=%d lnum=%d cat=%s foldclosed=%d'):format( - winid, - lnum, - m.category, - closed - ) - ) - if closed ~= -1 then - seen[m.category] = true - table.insert(folded, m.category) - end - end - end - end) - end - end - log.debug( - ('persist_folds: saving %d folded categories: %s'):format(#folded, table.concat(folded, ', ')) - ) - _store:set_folded_categories(folded) -end - ----@return nil function M.close() - if not task_winid or not vim.api.nvim_win_is_valid(task_winid) then - task_winid = nil - return - end - M.persist_folds() - if _store then - _store:save() - end - local wins = vim.api.nvim_list_wins() - if #wins == 1 then - vim.cmd.enew() - else + if task_winid and vim.api.nvim_win_is_valid(task_winid) then vim.api.nvim_win_close(task_winid, false) end task_winid = nil @@ -334,13 +55,19 @@ 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 @@ -350,7 +77,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 ]]) @@ -358,7 +85,6 @@ 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 @@ -366,25 +92,8 @@ function M.open_line(above) end local row = vim.api.nvim_win_get_cursor(0)[1] local insert_row = above and (row - 1) or row - local meta_pos = insert_row + 1 - - _rendering = true vim.bo[bufnr].modifiable = true vim.api.nvim_buf_set_lines(bufnr, insert_row, insert_row, false, { '- [ ] ' }) - _rendering = false - - table.insert(_meta, meta_pos, { type = 'task' }) - - local icons = config.get().icons - local total = vim.api.nvim_buf_line_count(bufnr) - for r = meta_pos, math.min(meta_pos + 1, total) do - vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, r - 1, r) - local m = _meta[r] - if m then - apply_inline_row(bufnr, r - 1, m, icons) - end - end - vim.api.nvim_win_set_cursor(0, { insert_row + 1, 6 }) vim.cmd('startinsert!') end @@ -405,109 +114,50 @@ function M.get_fold() end end ----@class pending.EolSegment ----@field type 'specifier'|'literal' ----@field key? 'c'|'r'|'d' ----@field text? string - ----@param fmt string ----@return pending.EolSegment[] -local function parse_eol_format(fmt) - local segments = {} - local pos = 1 - local len = #fmt - while pos <= len do - if fmt:sub(pos, pos) == '%' and pos + 1 <= len then - local key = fmt:sub(pos + 1, pos + 1) - if key == 'c' or key == 'r' or key == 'd' then - table.insert(segments, { type = 'specifier', key = key }) - pos = pos + 2 - else - table.insert(segments, { type = 'literal', text = '%' .. key }) - pos = pos + 2 - end - else - local next_pct = fmt:find('%%', pos + 1) - local chunk = next_pct and fmt:sub(pos, next_pct - 1) or fmt:sub(pos) - table.insert(segments, { type = 'literal', text = chunk }) - pos = pos + #chunk - end - end - return segments -end - ----@param segments pending.EolSegment[] ----@param m pending.LineMeta ----@param icons pending.Icons ----@return string[][] -local function build_eol_virt(segments, m, icons) - local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue' - local resolved = {} - for i, seg in ipairs(segments) do - if seg.type == 'specifier' then - local text, hl - if seg.key == 'c' and m.show_category and m.category then - text = icons.category .. ' ' .. m.category - hl = 'PendingHeader' - elseif seg.key == 'r' and m.recur then - text = icons.recur .. ' ' .. m.recur - hl = 'PendingRecur' - elseif seg.key == 'd' and m.due then - text = icons.due .. ' ' .. m.due - hl = due_hl - end - resolved[i] = text and { text = text, hl = hl, present = true } or { present = false } - else - resolved[i] = { text = seg.text, hl = 'Normal', literal = true } - end - end - - local virt_parts = {} - for i, r in ipairs(resolved) do - if r.literal then - local prev_present, next_present = false, false - for j = i - 1, 1, -1 do - if not resolved[j].literal then - prev_present = resolved[j].present - break - end - end - for j = i + 1, #resolved do - if not resolved[j].literal then - next_present = resolved[j].present - break - end - end - if prev_present and next_present then - table.insert(virt_parts, { r.text, r.hl }) - end - elseif r.present then - table.insert(virt_parts, { r.text, r.hl }) - end - end - return virt_parts -end - ---@param bufnr integer ---@param line_meta pending.LineMeta[] local function apply_extmarks(bufnr, line_meta) - local cfg = config.get() - local icons = cfg.icons - local eol_segments = parse_eol_format(cfg.view.eol_format or '%c %r %d') - vim.api.nvim_buf_clear_namespace(bufnr, ns_eol, 0, -1) - vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, 0, -1) + 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 - local virt_parts = build_eol_virt(eol_segments, m, icons) - if #virt_parts > 0 then - vim.api.nvim_buf_set_extmark(bufnr, ns_eol, row, 0, { - virt_text = virt_parts, + 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 } } + end + if virt_text then + vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { + virt_text = virt_text, + virt_text_pos = 'eol', + }) + end + elseif m.due then + vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { + virt_text = { { m.due, due_hl } }, virt_text_pos = 'eol', }) end + if m.status == 'done' then + local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' + local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) or 0 + vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, col_start, { + end_col = #line, + hl_group = 'PendingDone', + }) + end + elseif m.type == 'header' then + local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' + vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { + end_col = #line, + hl_group = 'PendingHeader', + }) end - apply_inline_row(bufnr, row, m, icons) end end @@ -517,134 +167,60 @@ 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, 'PendingPriority2', { link = 'DiagnosticError', default = true }) - vim.api.nvim_set_hl(0, 'PendingPriority3', { link = 'DiagnosticError', default = true }) - vim.api.nvim_set_hl(0, 'PendingWip', { link = 'DiagnosticInfo', default = true }) - vim.api.nvim_set_hl(0, 'PendingBlocked', { link = 'DiagnosticError', default = true }) - vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true }) - vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true }) -end - ----@return string -function M.get_foldtext() - local folding = config.resolve_folding() - if not folding.foldtext then - return vim.fn.foldtext() - end - local line = vim.fn.getline(vim.v.foldstart) - local cat = line:match('^#%s+(.+)$') or line - local task_count = vim.v.foldend - vim.v.foldstart - local icons = config.get().icons - local result = folding.foldtext - :gsub('%%c', cat) - :gsub('%%n', tostring(task_count)) - :gsub('(%d+) (%w+)s%)', function(n, word) - if n == '1' then - return n .. ' ' .. word .. ')' - end - return n .. ' ' .. word .. 's)' - end) - return icons.category .. ' ' .. result end local function snapshot_folds(bufnr) - if current_view ~= 'category' or not config.resolve_folding().enabled then + if current_view ~= 'category' then return end for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do - if _fold_state[winid] == nil and _initial_fold_loaded then - local state = {} - vim.api.nvim_win_call(winid, function() - for lnum, m in ipairs(_meta) do - if m.type == 'header' and m.category then - if vim.fn.foldclosed(lnum) ~= -1 then - state[m.category] = true - end + local state = {} + vim.api.nvim_win_call(winid, function() + for lnum, m in ipairs(_meta) do + if m.type == 'header' and m.category then + if vim.fn.foldclosed(lnum) ~= -1 then + state[m.category] = true end end - end) - _fold_state[winid] = state - end + end + end) + _fold_state[winid] = state end end local function restore_folds(bufnr) - log.debug( - ('restore_folds: view=%s folding_enabled=%s'):format( - tostring(current_view), - tostring(config.resolve_folding().enabled) - ) - ) - if current_view ~= 'category' or not config.resolve_folding().enabled then + if current_view ~= 'category' then return end for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do local state = _fold_state[winid] - _fold_state[winid] = nil - log.debug( - ('restore_folds: win=%d has_fold_state=%s initial_loaded=%s has_store=%s'):format( - winid, - tostring(state ~= nil), - tostring(_initial_fold_loaded), - tostring(_store ~= nil) - ) - ) - if not state and not _initial_fold_loaded and _store then - _initial_fold_loaded = true - local cats = _store:get_folded_categories() - log.debug( - ('restore_folds: loaded %d categories from store: %s'):format( - #cats, - table.concat(cats, ', ') - ) - ) - if #cats > 0 then - state = {} - for _, cat in ipairs(cats) do - state[cat] = true - end - end - end if state and next(state) ~= nil then - local applying = {} - for k in pairs(state) do - table.insert(applying, k) - end - log.debug(('restore_folds: applying folds for: %s'):format(table.concat(applying, ', '))) vim.api.nvim_win_call(winid, function() vim.cmd('normal! zx') local saved = vim.api.nvim_win_get_cursor(0) for lnum, m in ipairs(_meta) do if m.type == 'header' and m.category and state[m.category] then - log.debug(('restore_folds: folding lnum=%d cat=%s'):format(lnum, m.category)) vim.api.nvim_win_set_cursor(0, { lnum, 0 }) vim.cmd('normal! zc') end end vim.api.nvim_win_set_cursor(0, saved) end) + _fold_state[winid] = nil end end 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 return end - current_view = current_view or config.get().view.default - 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 + 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 lines, line_meta if current_view == 'priority' then @@ -653,45 +229,25 @@ function M.render(bufnr) lines, line_meta = views.category_view(tasks) end - if #lines == 0 and #_filter_predicates == 0 then - local default_cat = config.get().default_category - lines = { '# ' .. default_cat } - line_meta = { { type = 'header', category = default_cat } } - end - - if #_filter_predicates > 0 then - table.insert(lines, 1, 'FILTER: ' .. table.concat(_filter_predicates, ' ')) - table.insert(line_meta, 1, { type = 'filter' }) - end - _meta = line_meta - _dirty_rows = {} snapshot_folds(bufnr) vim.bo[bufnr].modifiable = true local saved = vim.bo[bufnr].undolevels vim.bo[bufnr].undolevels = -1 - _rendering = true vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - _rendering = false vim.bo[bufnr].modified = false vim.bo[bufnr].undolevels = saved setup_syntax(bufnr) apply_extmarks(bufnr, line_meta) - local folding = config.resolve_folding() for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do - if current_view == 'category' and folding.enabled then + if current_view == 'category' then vim.wo[winid].foldmethod = 'expr' vim.wo[winid].foldexpr = 'v:lua.require("pending.buffer").get_fold()' vim.wo[winid].foldlevel = 99 vim.wo[winid].foldenable = true - if folding.foldtext then - vim.wo[winid].foldtext = 'v:lua.require("pending.buffer").get_foldtext()' - else - vim.wo[winid].foldtext = 'foldtext()' - end else vim.wo[winid].foldmethod = 'manual' vim.wo[winid].foldenable = false @@ -700,9 +256,7 @@ function M.render(bufnr) restore_folds(bufnr) end ----@return nil function M.toggle_view() - snapshot_folds(task_bufnr) if current_view == 'category' then current_view = 'priority' else @@ -714,9 +268,7 @@ end ---@return integer bufnr function M.open() setup_highlights() - if _store then - _store:load() - end + store.load() if task_winid and vim.api.nvim_win_is_valid(task_winid) then vim.api.nvim_set_current_win(task_winid) @@ -727,7 +279,6 @@ function M.open() if not (task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr)) then task_bufnr = vim.api.nvim_create_buf(true, false) set_buf_options(task_bufnr) - M.attach_bytes(task_bufnr) end vim.cmd('botright new') diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua deleted file mode 100644 index 6ee3320..0000000 --- a/lua/pending/complete.lua +++ /dev/null @@ -1,177 +0,0 @@ -local config = require('pending.config') - ----@class pending.CompletionItem ----@field word string ----@field info string - ----@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 pending.CompletionItem[] -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 pending.CompletionItem[] -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 1d4c00a..b61f44a 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -1,96 +1,16 @@ ----@class pending.FoldingConfig ----@field foldtext? string|false - ----@class pending.ResolvedFolding ----@field enabled boolean ----@field foldtext string|false - ----@class pending.Icons ----@field pending string ----@field done string ----@field priority string ----@field wip string ----@field blocked string ----@field due string ----@field recur string ----@field category string - ---@class pending.GcalConfig ----@field remote_delete? boolean +---@field calendar? string ---@field credentials_path? string ----@field client_id? string ----@field client_secret? string - ----@class pending.GtasksConfig ----@field remote_delete? boolean ----@field credentials_path? string ----@field client_id? string ----@field client_secret? string - ----@class pending.S3Config ----@field bucket string ----@field key? string ----@field profile? string ----@field region? string - ----@class pending.SyncConfig ----@field remote_delete? boolean ----@field gcal? pending.GcalConfig ----@field gtasks? pending.GtasksConfig ----@field s3? pending.S3Config - ----@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 ----@field category? string|false ----@field recur? string|false ----@field move_down? string|false ----@field move_up? string|false ----@field wip? string|false ----@field blocked? string|false - ----@class pending.CategoryViewConfig ----@field order? string[] ----@field folding? boolean|pending.FoldingConfig - ----@class pending.QueueViewConfig - ----@class pending.ViewConfig ----@field default? 'category'|'priority' ----@field eol_format? string ----@field category? pending.CategoryViewConfig ----@field queue? pending.QueueViewConfig ---@class pending.Config ---@field data_path string +---@field default_view 'category'|'priority' ---@field default_category string ---@field date_format string ---@field date_syntax string ----@field recur_syntax string ----@field someday_date string ----@field input_date_formats? string[] +---@field category_order? string[] ---@field drawer_height? integer ----@field debug? boolean ----@field keymaps pending.Keymaps ----@field view pending.ViewConfig ----@field max_priority? integer ----@field sync? pending.SyncConfig ----@field icons pending.Icons +---@field gcal? pending.GcalConfig ---@class pending.config local M = {} @@ -98,59 +18,11 @@ local M = {} ---@type pending.Config local defaults = { data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', + default_view = 'category', default_category = 'Todo', date_format = '%b %d', date_syntax = 'due', - recur_syntax = 'rec', - someday_date = '9999-12-30', - max_priority = 3, - view = { - default = 'category', - eol_format = '%c %r %d', - category = { - order = {}, - folding = true, - }, - queue = {}, - }, - keymaps = { - close = 'q', - toggle = '', - view = '', - priority = 'g!', - date = 'gd', - undo = 'gz', - filter = 'gf', - 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', - category = 'gc', - recur = 'gr', - move_down = 'J', - move_up = 'K', - wip = 'gw', - blocked = 'gb', - priority_up = '', - priority_down = '', - }, - sync = {}, - icons = { - pending = ' ', - done = 'x', - priority = '!', - wip = '>', - blocked = '=', - due = '.', - recur = '~', - category = '#', - }, + category_order = {}, } ---@type pending.Config? @@ -166,20 +38,8 @@ function M.get() return _resolved end ----@return nil function M.reset() _resolved = nil end ----@return pending.ResolvedFolding -function M.resolve_folding() - local raw = M.get().view.category.folding - if raw == false then - return { enabled = false, foldtext = false } - elseif raw == true or raw == nil then - return { enabled = true, foldtext = '%c (%n tasks)' } - end - return { enabled = true, foldtext = raw.foldtext or false } -end - return M diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 723dee1..85f083c 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -1,5 +1,6 @@ local config = require('pending.config') local parse = require('pending.parse') +local store = require('pending.store') ---@class pending.ParsedEntry ---@field type 'task'|'header'|'blank' @@ -9,8 +10,6 @@ local parse = require('pending.parse') ---@field status? string ---@field category? string ---@field due? string ----@field rec? string ----@field rec_mode? string ---@field lnum integer ---@class pending.diff @@ -26,33 +25,19 @@ 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 = start, #lines do - local line = lines[i] - local id, body = line:match('^/(%d+)/(- %[.?%] .*)$') + for i, line in ipairs(lines) do + local id, body = line:match('^/(%d+)/(- %[.%] .*)$') if not id then - body = line:match('^(- %[.?%] .*)$') + body = line:match('^(- %[.%] .*)$') end if line == '' then table.insert(result, { type = 'blank', lnum = i }) elseif id or body then - local stripped = body:match('^- %[.?%] (.*)$') or body + local stripped = body:match('^- %[.%] (.*)$') or body local state_char = body:match('^- %[(.-)%]') or ' ' local priority = state_char == '!' and 1 or 0 - local status - if state_char == 'x' then - status = 'done' - elseif state_char == '>' then - status = 'wip' - elseif state_char == '=' then - status = 'blocked' - else - status = 'pending' - end + local status = state_char == 'x' and 'done' or 'pending' local description, metadata = parse.body(stripped) if description and description ~= '' then table.insert(result, { @@ -63,13 +48,11 @@ 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 @@ -78,13 +61,10 @@ function M.parse_buffer(lines) end ---@param lines string[] ----@param s pending.Store ----@param hidden_ids? table ----@return nil -function M.apply(lines, s, hidden_ids) +function M.apply(lines) local parsed = M.parse_buffer(lines) local now = timestamp() - local data = s:data() + local data = store.data() local old_by_id = {} for _, task in ipairs(data.tasks) do @@ -105,13 +85,11 @@ function M.apply(lines, s, hidden_ids) if entry.id and old_by_id[entry.id] then if seen_ids[entry.id] then - s:add({ + store.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 @@ -126,27 +104,14 @@ function M.apply(lines, s, hidden_ids) task.category = entry.category changed = true end - if entry.priority == 0 and task.priority > 0 then - task.priority = 0 - changed = true - elseif entry.priority > 0 and task.priority == 0 then + if task.priority ~= entry.priority then task.priority = entry.priority changed = true end - if entry.due ~= nil and task.due ~= entry.due then + if 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 @@ -165,13 +130,11 @@ function M.apply(lines, s, hidden_ids) end end else - s:add({ + store.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 @@ -180,14 +143,14 @@ function M.apply(lines, s, hidden_ids) end for id, task in pairs(old_by_id) do - if not seen_ids[id] and not (hidden_ids and hidden_ids[id]) then + if not seen_ids[id] then task.status = 'deleted' task['end'] = now task.modified = now end end - s:save() + store.save() end return M diff --git a/lua/pending/health.lua b/lua/pending/health.lua index f819269..8a12da4 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -1,6 +1,5 @@ local M = {} ----@return nil function M.check() vim.health.start('pending.nvim') @@ -10,54 +9,42 @@ function M.check() return end - config.get() + local cfg = config.get() vim.health.ok('Config loaded') + vim.health.info('Data path: ' .. cfg.data_path) - local store_ok, store = pcall(require, 'pending.store') - if not store_ok then - vim.health.error('Failed to load pending.store') - return - end - - local resolved_path = store.resolve_path() - vim.health.info('Store path: ' .. resolved_path) - - 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') + 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 - 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() + vim.health.warn('Data directory does not exist yet: ' .. data_dir) + end + + if vim.fn.filereadable(cfg.data_path) == 1 then + local store_ok, store = pcall(require, 'pending.store') + if store_ok then + local load_ok, err = pcall(store.load) + if load_ok then + local tasks = store.tasks() + vim.health.ok('Data file loaded: ' .. #tasks .. ' tasks') + else + vim.health.error('Failed to load data file: ' .. tostring(err)) end end + else + vim.health.info('No data file yet (will be created on first save)') + end + + if vim.fn.executable('curl') == 1 then + vim.health.ok('curl found (required for Google Calendar sync)') + else + vim.health.warn('curl not found (needed for Google Calendar sync)') + end + + if vim.fn.executable('openssl') == 1 then + vim.health.ok('openssl found (required for OAuth PKCE)') + else + vim.health.warn('openssl not found (needed for Google Calendar OAuth)') end end diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 02587c2..14b9c24 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -1,226 +1,24 @@ local buffer = require('pending.buffer') local diff = require('pending.diff') -local log = require('pending.log') 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 ~= 'done' and task.status ~= 'deleted' 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 boolean -local function require_saved() - local bufnr = buffer.bufnr() - if bufnr and vim.bo[bufnr].modified then - log.warn('Save changes first (:w).') - return false - end - return true -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 - elseif pred == 'wip' then - if task.status ~= 'wip' then - visible = false - break - end - elseif pred == 'blocked' then - if task.status ~= 'blocked' 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 not require_saved() then - return - end - 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', { @@ -234,64 +32,12 @@ function M._setup_autocmds(bufnr) group = group, buffer = bufnr, callback = function() - local cur_win = vim.api.nvim_get_current_win() - local tw = buffer.winid() - if tw and vim.api.nvim_win_is_valid(tw) and cur_win ~= tw then - vim.schedule(function() - local cursor = vim.api.nvim_win_is_valid(cur_win) and vim.api.nvim_win_get_cursor(cur_win) - or nil - if vim.api.nvim_win_is_valid(cur_win) and #vim.api.nvim_list_wins() > 1 then - pcall(vim.api.nvim_win_close, cur_win, false) - end - if vim.api.nvim_win_is_valid(tw) then - vim.api.nvim_set_current_win(tw) - if cursor then - pcall(vim.api.nvim_win_set_cursor, tw, cursor) - end - end - end) - return - end - if not tw or not vim.api.nvim_win_is_valid(tw) then - buffer.update_winid(cur_win) - end if not vim.bo[bufnr].modified then - get_store():load() + store.load() buffer.render(bufnr) end end, }) - vim.api.nvim_create_autocmd('TextChangedI', { - group = group, - buffer = bufnr, - callback = function() - if not vim.bo[bufnr].modified then - return - end - for row in pairs(buffer.dirty_rows()) do - buffer.clear_inline_row(bufnr, row) - end - end, - }) - vim.api.nvim_create_autocmd('TextChanged', { - group = group, - buffer = bufnr, - callback = function() - if not vim.bo[bufnr].modified then - return - end - buffer.reapply_dirty_inline(bufnr) - end, - }) - vim.api.nvim_create_autocmd('InsertLeave', { - group = group, - buffer = bufnr, - callback = function() - if vim.bo[bufnr].modified then - buffer.reapply_dirty_inline(bufnr) - end - end, - }) vim.api.nvim_create_autocmd('WinClosed', { group = group, callback = function(ev) @@ -300,221 +46,71 @@ function M._setup_autocmds(bufnr) end end, }) - vim.api.nvim_create_autocmd('VimLeavePre', { - group = group, - callback = function() - local bnr = buffer.bufnr() - log.debug( - ('VimLeavePre: bufnr=%s valid=%s'):format( - tostring(bnr), - tostring(bnr and vim.api.nvim_buf_is_valid(bnr)) - ) - ) - if bnr and vim.api.nvim_buf_is_valid(bnr) then - buffer.persist_folds() - get_store():save() - end - end, - }) 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, nowait = true } - - ---@type table - local actions = { - close = function() - buffer.close() - end, - toggle = function() - M.toggle_complete() - end, - view = function() - if not require_saved() then - return - end - buffer.toggle_view() - end, - priority = function() - M.toggle_priority() - end, - date = function() - M.prompt_date() - end, - category = function() - M.prompt_category() - end, - recur = function() - M.prompt_recur() - end, - move_down = function() - M.move_task('down') - end, - move_up = function() - M.move_task('up') - end, - wip = function() - M.toggle_status('wip') - end, - blocked = function() - M.toggle_status('blocked') - end, - priority_up = function() - M.increment_priority() - end, - priority_down = function() - M.decrement_priority() - end, - undo = function() - M.undo_write() - end, - filter = function() - if not require_saved() then - return - end - 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] - log.debug(('mapping motion %s → %s (buf=%d)'):format(name, key or 'nil', bufnr)) - if key and key ~= false then - vim.keymap.set({ 'n', 'x', 'o' }, key --[[@as string]], function() - fn(vim.v.count1) - end, opts) - end - end + local 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) end ---@param bufnr integer ----@return nil function M._on_write(bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - 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 = {} + local snapshot = store.snapshot() + table.insert(_undo_states, snapshot) + if #_undo_states > UNDO_MAX then + table.remove(_undo_states, 1) end - 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() + diff.apply(lines) buffer.render(bufnr) end ----@return nil function M.undo_write() - if not require_saved() then + if #_undo_states == 0 then + vim.notify('Nothing to undo.', vim.log.levels.WARN) return end - local s = get_store() - local stack = s:undo_stack() - if #stack == 0 then - log.warn('Nothing to undo.') - return - end - local state = table.remove(stack) - s:replace_tasks(state) - _save_and_notify() + local state = table.remove(_undo_states) + store.replace_tasks(state) + store.save() buffer.render(buffer.bufnr()) end ----@return nil function M.toggle_complete() local bufnr = buffer.bufnr() if not bufnr then return end - if not require_saved() then - return - end local row = vim.api.nvim_win_get_cursor(0)[1] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then @@ -524,30 +120,16 @@ function M.toggle_complete() if not id then return end - local s = get_store() - local task = s:get(id) + local task = store.get(id) if not task then return end if task.status == 'done' then - s:update(id, { status = 'pending', ['end'] = vim.NIL }) + store.update(id, { status = 'pending', ['end'] = vim.NIL }) else - if task.recur and task.due then - local recur = require('pending.recur') - local mode = task.recur_mode or 'scheduled' - local next_date = recur.next_due(task.due, task.recur, mode) - 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' }) + store.update(id, { status = 'done' }) end - _save_and_notify() + store.save() buffer.render(bufnr) for lnum, m in ipairs(buffer.meta()) do if m.id == id then @@ -557,74 +139,11 @@ function M.toggle_complete() end end ----@param id_str? string ----@return nil -function M.done(id_str) - local id - if not id_str or id_str == '' then - if not require_saved() then - return - end - local row = vim.api.nvim_win_get_cursor(0)[1] - local meta = buffer.meta() - if not meta[row] or meta[row].type ~= 'task' then - log.error('Cursor is not on a task line.') - return - end - id = meta[row].id - if not id then - return - end - else - id = tonumber(id_str) - if not id then - log.error('Invalid task ID: ' .. tostring(id_str)) - return - end - end - local s = get_store() - s:load() - local task = s:get(id) - if not task then - log.error('No task with ID ' .. id .. '.') - return - end - local was_done = task.status == 'done' - if was_done then - s:update(id, { status = 'pending', ['end'] = vim.NIL }) - else - if task.recur and task.due then - local recur = require('pending.recur') - local mode = task.recur_mode or 'scheduled' - local next_date = recur.next_due(task.due, task.recur, mode) - 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 - _save_and_notify() - local bufnr = buffer.bufnr() - if bufnr and vim.api.nvim_buf_is_valid(bufnr) then - buffer.render(bufnr) - end - log.info('Task #' .. id .. ' marked ' .. (was_done and 'pending' or 'done') .. '.') -end - ----@return nil function M.toggle_priority() local bufnr = buffer.bufnr() if not bufnr then return end - if not require_saved() then - return - end local row = vim.api.nvim_win_get_cursor(0)[1] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then @@ -634,15 +153,13 @@ function M.toggle_priority() if not id then return end - local s = get_store() - local task = s:get(id) + local task = store.get(id) if not task then return end - local max = require('pending.config').get().max_priority or 3 - local new_priority = (task.priority + 1) % (max + 1) - s:update(id, { priority = new_priority }) - _save_and_notify() + local new_priority = task.priority > 0 and 0 or 1 + store.update(id, { priority = new_priority }) + store.save() buffer.render(bufnr) for lnum, m in ipairs(buffer.meta()) do if m.id == id then @@ -652,65 +169,11 @@ function M.toggle_priority() end end ----@param delta integer ----@return nil -local function adjust_priority(delta) - local bufnr = buffer.bufnr() - if not bufnr then - return - end - if not require_saved() then - return - end - local row = vim.api.nvim_win_get_cursor(0)[1] - local meta = buffer.meta() - if not meta[row] or meta[row].type ~= 'task' then - return - end - local id = meta[row].id - if not id then - return - end - local s = get_store() - local task = s:get(id) - if not task then - return - end - local max = require('pending.config').get().max_priority or 3 - local new_priority = math.max(0, math.min(max, task.priority + delta)) - if new_priority == task.priority then - return - end - 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 - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) - break - end - end -end - ----@return nil -function M.increment_priority() - adjust_priority(1) -end - ----@return nil -function M.decrement_priority() - adjust_priority(-1) -end - ----@return nil function M.prompt_date() local bufnr = buffer.bufnr() if not bufnr then return end - if not require_saved() then - return - end local row = vim.api.nvim_win_get_cursor(0)[1] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then @@ -720,7 +183,7 @@ function M.prompt_date() if not id then return end - vim.ui.input({ prompt = 'Due date (today, +3d, fri@2pm, etc.): ' }, function(input) + vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input) if not input then return end @@ -729,396 +192,90 @@ 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$') - and not due:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$') - then - log.error('Invalid date format. Use YYYY-MM-DD or YYYY-MM-DDThh:mm.') + 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) return end end - get_store():update(id, { due = due }) - _save_and_notify() + store.update(id, { due = due }) + store.save() buffer.render(bufnr) end) end ----@param target_status 'wip'|'blocked' ----@return nil -function M.toggle_status(target_status) - local bufnr = buffer.bufnr() - if not bufnr then - return - end - if not require_saved() then - return - end - local row = vim.api.nvim_win_get_cursor(0)[1] - local meta = buffer.meta() - if not meta[row] or meta[row].type ~= 'task' then - return - end - local id = meta[row].id - if not id then - return - end - local s = get_store() - local task = s:get(id) - if not task then - return - end - if task.status == target_status then - s:update(id, { status = 'pending' }) - else - s:update(id, { status = target_status }) - end - _save_and_notify() - buffer.render(bufnr) - for lnum, m in ipairs(buffer.meta()) do - if m.id == id then - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) - break - end - end -end - ----@param direction 'up'|'down' ----@return nil -function M.move_task(direction) - local bufnr = buffer.bufnr() - if not bufnr then - return - end - if not require_saved() then - return - end - local row = vim.api.nvim_win_get_cursor(0)[1] - local meta = buffer.meta() - if not meta[row] or meta[row].type ~= 'task' then - return - end - local id = meta[row].id - if not id then - return - end - - local target_row - if direction == 'down' then - target_row = row + 1 - else - target_row = row - 1 - end - if not meta[target_row] or meta[target_row].type ~= 'task' then - return - end - - local current_view_name = buffer.current_view_name() or 'category' - if current_view_name == 'category' then - if meta[target_row].category ~= meta[row].category then - return - end - end - - local target_id = meta[target_row].id - if not target_id then - return - end - - local s = get_store() - local task_a = s:get(id) - local task_b = s:get(target_id) - if not task_a or not task_b then - return - end - - if task_a.order == 0 or task_b.order == 0 then - local tasks - if current_view_name == 'category' then - tasks = {} - for _, t in ipairs(s:active_tasks()) do - if t.category == task_a.category then - table.insert(tasks, t) - end - end - else - tasks = s:active_tasks() - end - table.sort(tasks, function(a, b) - if a.order ~= b.order then - return a.order < b.order - end - return a.id < b.id - end) - for i, t in ipairs(tasks) do - s:update(t.id, { order = i }) - end - task_a = s:get(id) - task_b = s:get(target_id) - if not task_a or not task_b then - return - end - end - - local order_a, order_b = task_a.order, task_b.order - s:update(id, { order = order_b }) - s:update(target_id, { order = order_a }) - _save_and_notify() - buffer.render(bufnr) - - for lnum, m in ipairs(buffer.meta()) do - if m.id == id then - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) - break - end - end -end - ----@return nil -function M.prompt_category() - local bufnr = buffer.bufnr() - if not bufnr then - return - end - if not require_saved() then - return - end - local row = vim.api.nvim_win_get_cursor(0)[1] - local meta = buffer.meta() - if not meta[row] or meta[row].type ~= 'task' then - return - end - local id = meta[row].id - if not id then - return - end - local s = get_store() - local seen = {} - local categories = {} - for _, task in ipairs(s:active_tasks()) do - if task.category and not seen[task.category] then - seen[task.category] = true - table.insert(categories, task.category) - end - end - table.sort(categories) - vim.ui.select(categories, { prompt = 'Category: ' }, function(choice) - if not choice then - return - end - s:update(id, { category = choice }) - _save_and_notify() - buffer.render(bufnr) - end) -end - ----@return nil -function M.prompt_recur() - local bufnr = buffer.bufnr() - if not bufnr then - return - end - if not require_saved() then - return - end - local row = vim.api.nvim_win_get_cursor(0)[1] - local meta = buffer.meta() - if not meta[row] or meta[row].type ~= 'task' then - return - end - local id = meta[row].id - if not id then - return - end - vim.ui.input({ prompt = 'Recurrence (e.g. weekly, !daily): ' }, function(input) - if not input then - return - end - local s = get_store() - if input == '' then - s:update(id, { recur = vim.NIL, recur_mode = vim.NIL }) - _save_and_notify() - buffer.render(bufnr) - log.info('Task #' .. id .. ': recurrence removed.') - return - end - local raw_spec = input - local rec_mode = nil - if raw_spec:sub(1, 1) == '!' then - rec_mode = 'completion' - raw_spec = raw_spec:sub(2) - end - local recur = require('pending.recur') - if not recur.validate(raw_spec) then - log.error('Invalid recurrence pattern: ' .. input) - return - end - s:update(id, { recur = raw_spec, recur_mode = rec_mode }) - _save_and_notify() - buffer.render(bufnr) - log.info('Task #' .. id .. ': recurrence set to ' .. raw_spec .. '.') - end) -end - ---@param text string ----@return nil function M.add(text) if not text or text == '' then - log.error('Usage: :Pending add ') + vim.notify('Usage: :Pending add ', vim.log.levels.ERROR) return end - local s = get_store() - s:load() + store.load() local description, metadata = parse.command_add(text) if not description or description == '' then - log.error('Task must have a description.') + vim.notify('Pending must have a description.', vim.log.levels.ERROR) return end - s:add({ + store.add({ description = description, category = metadata.cat, due = metadata.due, - recur = metadata.rec, - recur_mode = metadata.rec_mode, - priority = metadata.priority, }) - _save_and_notify() + store.save() local bufnr = buffer.bufnr() if bufnr and vim.api.nvim_buf_is_valid(bufnr) then buffer.render(bufnr) end - log.info('Task added: ' .. description) + vim.notify('Pending added: ' .. description) end ----@type string[]? -local _sync_backends = nil - ----@type table? -local _sync_backend_set = nil - ----@return string[], table -local function discover_backends() - if _sync_backends then - return _sync_backends, _sync_backend_set --[[@as table]] - end - _sync_backends = {} - _sync_backend_set = {} - local paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true) - for _, path in ipairs(paths) do - local name = vim.fn.fnamemodify(path, ':t:r') - local ok, mod = pcall(require, 'pending.sync.' .. name) - if ok and type(mod) == 'table' and mod.name then - table.insert(_sync_backends, mod.name) - _sync_backend_set[mod.name] = true - end - end - table.sort(_sync_backends) - return _sync_backends, _sync_backend_set -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) +function M.sync() + local ok, gcal = pcall(require, 'pending.sync.gcal') if not ok then - log.error('Unknown sync backend: ' .. backend_name) + vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR) return end - if not action or action == '' then - local actions = {} - for k, v in pairs(backend) do - if - type(v) == 'function' - and k:sub(1, 1) ~= '_' - and k ~= 'health' - and k ~= 'auth' - and k ~= 'auth_complete' - then - table.insert(actions, k) - end - end - table.sort(actions) - log.info(backend_name .. ' actions: ' .. table.concat(actions, ', ')) - return - end - if action == 'health' or type(backend[action]) ~= 'function' then - log.error(backend_name .. ": No '" .. action .. "' action.") - return - end - backend[action]() + gcal.sync() end ----@param msg string ----@param callback fun() -local function confirm(msg, callback) - vim.ui.input({ prompt = msg .. ' [y/N]: ' }, function(input) - if input and input:lower() == 'y' then - callback() - end - end) -end - ----@param arg? string ----@return nil -function M.archive(arg) - local days - if arg and arg ~= '' then - days = parse.parse_duration_to_days(arg) - if not days then - log.error('Invalid duration: ' .. arg .. '. Use e.g. 7d, 2w, 3m, or a bare number.') - return - end - else - days = 30 - end - local cutoff = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (days * 86400)) --[[@as string]] - local s = get_store() - local tasks = s:tasks() - log.debug(('archive: days=%d cutoff=%s total_tasks=%d'):format(days, cutoff, #tasks)) - local count = 0 +---@param days? integer +function M.archive(days) + days = days or 30 + local cutoff = os.time() - (days * 86400) + local tasks = store.tasks() + local archived = 0 + local kept = {} for _, task in ipairs(tasks) do if (task.status == 'done' or task.status == 'deleted') and task['end'] then - if task['end'] < cutoff then - count = count + 1 - end - end - end - if count == 0 then - log.info('No tasks to archive.') - return - end - confirm( - 'Archive ' - .. count - .. ' task' - .. (count == 1 and '' or 's') - .. ' completed/deleted more than ' - .. days - .. 'd ago?', - function() - local kept = {} - for _, task in ipairs(tasks) do - if (task.status == 'done' or task.status == 'deleted') and task['end'] then - if task['end'] < cutoff then - goto skip - end + local y, mo, d, h, mi, s = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$') + if y then + local t = os.time({ + year = tonumber(y) --[[@as integer]], + month = tonumber(mo) --[[@as integer]], + day = tonumber(d) --[[@as integer]], + hour = tonumber(h) --[[@as integer]], + min = tonumber(mi) --[[@as integer]], + sec = tonumber(s) --[[@as integer]], + }) + if t < cutoff then + archived = archived + 1 + goto skip end - table.insert(kept, task) - ::skip:: - end - s:replace_tasks(kept) - _save_and_notify() - log.info('Archived ' .. count .. ' task' .. (count == 1 and '' or 's') .. '.') - local bufnr = buffer.bufnr() - if bufnr and vim.api.nvim_buf_is_valid(bufnr) then - buffer.render(bufnr) end end - ) + table.insert(kept, task) + ::skip:: + end + store.replace_tasks(kept) + store.save() + vim.notify('Archived ' .. archived .. ' tasks.') + local bufnr = buffer.bufnr() + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + buffer.render(bufnr) + end end ----@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 @@ -1126,14 +283,9 @@ 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 (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] ' + 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] ' table.insert(qf_items, { bufnr = bufnr, lnum = lnum, @@ -1143,15 +295,10 @@ function M.due() end end else - 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] ' + 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 text = label .. task.description if task.category then text = text .. ' [' .. task.category .. ']' @@ -1162,7 +309,7 @@ function M.due() end if #qf_items == 0 then - log.info('No due or overdue tasks.') + vim.notify('No due or overdue tasks.') return end @@ -1170,253 +317,68 @@ function M.due() vim.cmd('copen') end ----@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') +function M.show_help() local cfg = require('pending.config').get() local dk = cfg.date_syntax or 'due' - local rk = cfg.recur_syntax or 'rec' - - local bangs = token:match('^%+(!+)$') - if bangs then - local max = cfg.max_priority or 3 - local level = math.min(#bangs, max) - return 'priority', level, 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) - local id = id_str and tonumber(id_str) - if not id then - local bufnr = buffer.bufnr() - if bufnr and vim.api.nvim_buf_is_valid(bufnr) then - local row = vim.api.nvim_win_get_cursor(0)[1] - local meta = buffer.meta() - if meta[row] and meta[row].type == 'task' and meta[row].id then - id = meta[row].id - if id_str and id_str ~= '' then - rest = rest and (id_str .. ' ' .. rest) or id_str - end - end - end - if not id then - if id_str and id_str ~= '' then - log.error('Invalid task ID: ' .. id_str) - else - log.error( - 'Usage: :Pending edit [] [due:] [cat:] [rec:] [+!] [-!] [-due] [-cat] [-rec]' - ) - end - return - end - end - - local s = get_store() - s:load() - local task = s:get(id) - if not task then - log.error('No task with ID ' .. id .. '.') - return - end - - if not rest or rest == '' then - log.error( - 'Usage: :Pending edit [due:] [cat:] [rec:] [+!] [-!] [-due] [-cat] [-rec]' - ) - 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 - log.error(err) - 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 - if value == 0 then - table.insert(feedback, 'priority removed') - else - table.insert(feedback, 'priority set to ' .. value) - end - 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 - - log.info('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', ') .. '.') -end - ----@param args? string ----@return nil -function M.auth(args) - local parts = {} - for w in (args or ''):gmatch('%S+') do - table.insert(parts, w) - end - - local backend_name = parts[1] - local sub_action = parts[2] - - local backends_list = discover_backends() - local auth_backends = {} - for _, name in ipairs(backends_list) do - local ok, mod = pcall(require, 'pending.sync.' .. name) - if ok and type(mod.auth) == 'function' then - table.insert(auth_backends, { name = name, mod = mod }) - end - end - - if backend_name then - local found = false - for _, b in ipairs(auth_backends) do - if b.name == backend_name then - b.mod.auth(sub_action) - found = true - break - end - end - if not found then - log.error('No auth method for backend: ' .. backend_name) - end - elseif #auth_backends == 1 then - auth_backends[1].mod.auth() - elseif #auth_backends > 1 then - local names = {} - for _, b in ipairs(auth_backends) do - table.insert(names, b.name) - end - vim.ui.select(names, { prompt = 'Authenticate backend: ' }, function(choice) - if not choice then - return - end - for _, b in ipairs(auth_backends) do - if b.name == choice then - b.mod.auth() - break - end - end - end) - else - log.warn('No sync backends with auth support found.') - end + 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 }) end ---@param args string ----@return nil function M.command(args) if not args or args == '' then M.open() @@ -1425,38 +387,18 @@ function M.command(args) local cmd, rest = args:match('^(%S+)%s*(.*)') if cmd == 'add' then M.add(rest) - elseif cmd == 'done' then - M.done(rest:match('^(%S+)')) - elseif cmd == 'edit' then - local id_str, edit_rest = rest:match('^(%S+)%s*(.*)') - M.edit(id_str, edit_rest) - elseif cmd == 'auth' then - M.auth(rest) - elseif select(2, discover_backends())[cmd] then - local action = rest:match('^(%S+)') - run_sync(cmd, action) + elseif cmd == 'sync' then + M.sync() elseif cmd == 'archive' then - M.archive(rest ~= '' and rest or nil) + 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() else - log.error('Unknown subcommand: ' .. cmd) + vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR) end end ----@return string[] -function M.sync_backends() - return (discover_backends()) -end - ----@return table -function M.sync_backend_set() - local _, set = discover_backends() - return set -end - return M diff --git a/lua/pending/log.lua b/lua/pending/log.lua deleted file mode 100644 index 1f37c4e..0000000 --- a/lua/pending/log.lua +++ /dev/null @@ -1,30 +0,0 @@ ----@class pending.log -local M = {} - -local PREFIX = '[pending.nvim]: ' - ----@param msg string -function M.info(msg) - vim.notify(PREFIX .. msg) -end - ----@param msg string -function M.warn(msg) - vim.notify(PREFIX .. msg, vim.log.levels.WARN) -end - ----@param msg string -function M.error(msg) - vim.notify(PREFIX .. msg, vim.log.levels.ERROR) -end - ----@param msg string ----@param override? boolean -function M.debug(msg, override) - local cfg = require('pending.config').get() - if cfg.debug or override then - vim.notify(PREFIX .. msg, vim.log.levels.DEBUG) - end -end - -return M diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 5a705ef..ebe909a 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -1,12 +1,5 @@ local config = require('pending.config') ----@class pending.Metadata ----@field due? string ----@field cat? string ----@field rec? string ----@field rec_mode? 'scheduled'|'completion' ----@field priority? integer - ---@class pending.parse local M = {} @@ -31,92 +24,11 @@ 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, @@ -127,402 +39,53 @@ 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 name string ----@return integer? -local function month_name_to_num(name) - return month_map[name:lower():sub(1, 3)] -end - ----@param fmt string ----@return string, string[] -local function input_format_to_pattern(fmt) - local fields = {} - local parts = {} - local i = 1 - while i <= #fmt do - local c = fmt:sub(i, i) - if c == '%' and i < #fmt then - local spec = fmt:sub(i + 1, i + 1) - if spec == '%' then - parts[#parts + 1] = '%%' - i = i + 2 - elseif spec == 'Y' then - fields[#fields + 1] = 'year' - parts[#parts + 1] = '(%d%d%d%d)' - i = i + 2 - elseif spec == 'y' then - fields[#fields + 1] = 'year2' - parts[#parts + 1] = '(%d%d)' - i = i + 2 - elseif spec == 'm' then - fields[#fields + 1] = 'month_num' - parts[#parts + 1] = '(%d%d?)' - i = i + 2 - elseif spec == 'd' or spec == 'e' then - fields[#fields + 1] = 'day' - parts[#parts + 1] = '(%d%d?)' - i = i + 2 - elseif spec == 'b' or spec == 'B' then - fields[#fields + 1] = 'month_name' - parts[#parts + 1] = '(%a+)' - i = i + 2 - else - parts[#parts + 1] = vim.pesc(c) - i = i + 1 - end - else - parts[#parts + 1] = vim.pesc(c) - i = i + 1 - end - end - return '^' .. table.concat(parts) .. '$', fields -end - ----@param date_input string ----@param time_suffix? string ----@return string? -local function try_input_date_formats(date_input, time_suffix) - local fmts = config.get().input_date_formats - if not fmts or #fmts == 0 then - return nil - end - local today = os.date('*t') --[[@as osdate]] - for _, fmt in ipairs(fmts) do - local pat, fields = input_format_to_pattern(fmt) - local caps = { date_input:match(pat) } - if caps[1] ~= nil then - local year, month, day - for j = 1, #fields do - local field = fields[j] - local val = caps[j] - if field == 'year' then - year = tonumber(val) - elseif field == 'year2' then - local y = tonumber(val) --[[@as integer]] - year = y + (y >= 70 and 1900 or 2000) - elseif field == 'month_num' then - month = tonumber(val) - elseif field == 'day' then - day = tonumber(val) - elseif field == 'month_name' then - month = month_name_to_num(val) - end - end - if month and day then - if not year then - year = today.year - if month < today.month or (month == today.month and day < today.day) then - year = year + 1 - end - end - local t = os.time({ year = year, month = month, day = day }) - local check = os.date('*t', t) --[[@as osdate]] - if check.year == year and check.month == month and check.day == day then - return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix) - end - end - end - end - return nil -end - ---@param text string ---@return string|nil function M.resolve_date(text) - 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 lower = text:lower() local today = os.date('*t') --[[@as osdate]] - 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 - ) + if lower == 'today' then + return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]] end if lower == 'tomorrow' 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 == '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) + return os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + 1 }) + ) --[[@as string]] end local 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 - - 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 - ) + return os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month, + day = today.day + ( + tonumber(n) --[[@as integer]] + ), + }) + ) --[[@as string]] end local target_wday = weekday_map[lower] if target_wday then local current_wday = today.wday local delta = (target_wday - current_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 - ) + return os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + delta }) + ) --[[@as string]] end - return try_input_date_formats(date_input, time_suffix) + return nil end ---@param text string ---@return string description ----@return pending.Metadata metadata +---@return { due?: string, cat?: string } metadata function M.body(text) local tokens = {} for token in text:gmatch('%S+') do @@ -532,10 +95,8 @@ function M.body(text) local metadata = {} local i = #tokens local dk = date_key() - local rk = recur_key() - local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d[T%d:]*)$' + local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$' local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$' - local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$' while i >= 1 do local token = tokens[i] @@ -544,7 +105,7 @@ function M.body(text) if metadata.due then break end - if not is_valid_datetime(due_val) then + if not is_valid_date(due_val) then break end metadata.due = due_val @@ -570,35 +131,7 @@ function M.body(text) metadata.cat = cat_val i = i - 1 else - local pri_bangs = token:match('^%+(!+)$') - if pri_bangs then - if metadata.priority then - break - end - local max = config.get().max_priority or 3 - metadata.priority = math.min(#pri_bangs, max) - i = i - 1 - else - 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 + break end end end @@ -615,7 +148,7 @@ end ---@param text string ---@return string description ----@return pending.Metadata metadata +---@return { due?: string, cat?: string } metadata function M.command_add(text) local cat_prefix = text:match('^(%S.-):%s') if cat_prefix then @@ -632,66 +165,4 @@ 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 - ----@param s? string ----@return integer? -function M.parse_duration_to_days(s) - if s == nil or s == '' then - return nil - end - local n = s:match('^(%d+)d$') - if n then - return tonumber(n) --[[@as integer]] - end - n = s:match('^(%d+)w$') - if n then - return tonumber(n) --[[@as integer]] - * 7 - end - n = s:match('^(%d+)m$') - if n then - return tonumber(n) --[[@as integer]] - * 30 - end - n = s:match('^(%d+)$') - if n then - return tonumber(n) --[[@as integer]] - end - return nil -end - return M diff --git a/lua/pending/recur.lua b/lua/pending/recur.lua deleted file mode 100644 index 9c647aa..0000000 --- a/lua/pending/recur.lua +++ /dev/null @@ -1,188 +0,0 @@ ----@class pending.RecurSpec ----@field freq 'daily'|'weekly'|'monthly'|'yearly' ----@field interval integer ----@field byday? string[] ----@field from_completion boolean ----@field _raw? string - ----@class pending.recur -local M = {} - ----@type table -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 fcf420e..5838414 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -3,12 +3,10 @@ local config = require('pending.config') ---@class pending.Task ---@field id integer ---@field description string ----@field status 'pending'|'done'|'deleted'|'wip'|'blocked' +---@field status 'pending'|'done'|'deleted' ---@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 @@ -19,39 +17,21 @@ local config = require('pending.config') ---@field version integer ---@field next_id integer ---@field tasks pending.Task[] ----@field undo pending.Task[][] ----@field folded_categories string[] - ----@class pending.TaskFields ----@field description string ----@field status? string ----@field category? string ----@field priority? integer ----@field due? string ----@field recur? string ----@field recur_mode? string ----@field order? integer ----@field _extra? table - ----@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 = {}, - folded_categories = {}, } end @@ -76,8 +56,6 @@ local known_fields = { category = true, priority = true, due = true, - recur = true, - recur_mode = true, entry = true, modified = true, ['end'] = true, @@ -103,12 +81,6 @@ 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 @@ -133,8 +105,6 @@ 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'], @@ -153,18 +123,18 @@ local function table_to_task(t) end ---@return pending.Data -function Store:load() - local path = self.path +function M.load() + local path = config.get().data_path local f = io.open(path, 'r') if not f then - self._data = empty_data() - return self._data + _data = empty_data() + return _data end local content = f:read('*a') f:close() if content == '' then - self._data = empty_data() - return self._data + _data = empty_data() + return _data end local ok, decoded = pcall(vim.json.decode, content) if not ok then @@ -179,52 +149,31 @@ function Store:load() .. '. Please update the plugin.' ) end - self._data = { + _data = { version = decoded.version or SUPPORTED_VERSION, next_id = decoded.next_id or 1, tasks = {}, - undo = {}, - folded_categories = decoded.folded_categories or {}, } for _, t in ipairs(decoded.tasks or {}) do - table.insert(self._data.tasks, table_to_task(t)) + table.insert(_data.tasks, table_to_task(t)) end - for _, snapshot in ipairs(decoded.undo or {}) do - if type(snapshot) == 'table' then - local tasks = {} - for _, raw in ipairs(snapshot) do - table.insert(tasks, table_to_task(raw)) - end - table.insert(self._data.undo, tasks) - end - end - return self._data + return _data end ----@return nil -function Store:save() - if not self._data then +function M.save() + if not _data then return end - local path = self.path + local path = config.get().data_path ensure_dir(path) local out = { - version = self._data.version, - next_id = self._data.next_id, + version = _data.version, + next_id = _data.next_id, tasks = {}, - undo = {}, - folded_categories = self._data.folded_categories, } - for _, task in ipairs(self._data.tasks) do + for _, task in ipairs(_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') @@ -241,22 +190,22 @@ function Store:save() end ---@return pending.Data -function Store:data() - if not self._data then - self:load() +function M.data() + if not _data then + M.load() end - return self._data --[[@as pending.Data]] + return _data --[[@as pending.Data]] end ---@return pending.Task[] -function Store:tasks() - return self:data().tasks +function M.tasks() + return M.data().tasks end ---@return pending.Task[] -function Store:active_tasks() +function M.active_tasks() local result = {} - for _, task in ipairs(self:tasks()) do + for _, task in ipairs(M.tasks()) do if task.status ~= 'deleted' then table.insert(result, task) end @@ -266,8 +215,8 @@ end ---@param id integer ---@return pending.Task? -function Store:get(id) - for _, task in ipairs(self:tasks()) do +function M.get(id) + for _, task in ipairs(M.tasks()) do if task.id == id then return task end @@ -275,10 +224,10 @@ function Store:get(id) return nil end ----@param fields pending.TaskFields +---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, order?: integer, _extra?: table } ---@return pending.Task -function Store:add(fields) - local data = self:data() +function M.add(fields) + local data = M.data() local now = timestamp() local task = { id = data.next_id, @@ -287,8 +236,6 @@ function Store: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, @@ -303,19 +250,15 @@ end ---@param id integer ---@param fields table ---@return pending.Task? -function Store:update(id, fields) - local task = self:get(id) +function M.update(id, fields) + local task = M.get(id) if not task then return nil end local now = timestamp() for k, v in pairs(fields) do if k ~= 'id' and k ~= 'entry' then - if v == vim.NIL then - task[k] = nil - else - task[k] = v - end + task[k] = v end end task.modified = now @@ -327,14 +270,14 @@ end ---@param id integer ---@return pending.Task? -function Store:delete(id) - return self:update(id, { status = 'deleted', ['end'] = timestamp() }) +function M.delete(id) + return M.update(id, { status = 'deleted', ['end'] = timestamp() }) end ---@param id integer ---@return integer? -function Store:find_index(id) - for i, task in ipairs(self:tasks()) do +function M.find_index(id) + for i, task in ipairs(M.tasks()) do if task.id == id then return i end @@ -343,15 +286,14 @@ function Store:find_index(id) end ---@param tasks pending.Task[] ----@return nil -function Store:replace_tasks(tasks) - self:data().tasks = tasks +function M.replace_tasks(tasks) + M.data().tasks = tasks end ---@return pending.Task[] -function Store:snapshot() +function M.snapshot() local result = {} - for _, task in ipairs(self:active_tasks()) do + for _, task in ipairs(M.active_tasks()) do local copy = {} for k, v in pairs(task) do if k ~= '_extra' then @@ -369,48 +311,13 @@ function Store:snapshot() return result end ----@return pending.Task[][] -function Store:undo_stack() - return self:data().undo -end - ----@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 +function M.set_next_id(id) + M.data().next_id = id end ----@return string[] -function Store:get_folded_categories() - return self:data().folded_categories -end - ----@param cats string[] ----@return nil -function Store:set_folded_categories(cats) - self:data().folded_categories = cats -end - ----@return nil -function Store:unload() - self._data = nil -end - ----@param path string ----@return pending.Store -function M.new(path) - return setmetatable({ path = path, _data = nil }, Store) -end - ----@return string -function M.resolve_path() - return config.get().data_path +function M.unload() + _data = nil end return M diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index d316375..6635575 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -1,61 +1,384 @@ local config = require('pending.config') -local log = require('pending.log') -local oauth = require('pending.sync.oauth') -local util = require('pending.sync.util') +local store = require('pending.store') 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' ----@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) +---@class pending.GcalCredentials +---@field client_id string +---@field client_secret string +---@field redirect_uris? string[] + +---@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) ) - if err then - return nil, err +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 - local result = {} - for _, item in ipairs(data and data.items or {}) do - if item.summary then - result[item.summary] = item.id - end + if body then + table.insert(args, '-d') + table.insert(args, body) end - return result, nil + 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 ----@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 - local body = vim.json.encode({ summary = name }) - local created, err = - oauth.curl_request('POST', BASE_URL .. '/calendars', oauth.auth_headers(access_token), body) +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 id = created and created.id - if id then - existing[name] = id + + for _, item in ipairs(data and data.items or {}) do + if item.summary == cal_name then + return item.id, nil + end end - return id, nil + + local body = vim.json.encode({ summary = cal_name }) + local created, create_err = + curl_request('POST', BASE_URL .. '/calendars', auth_headers(access_token), body) + if create_err then + return nil, create_err + end + + return created and created.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]] @@ -76,10 +399,10 @@ local function create_event(access_token, calendar_id, task) private = { taskId = tostring(task.id) }, }, } - local data, err = oauth.curl_request( + local data, err = curl_request( 'POST', - BASE_URL .. '/calendars/' .. oauth.url_encode(calendar_id) .. '/events', - oauth.auth_headers(access_token), + BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events', + auth_headers(access_token), vim.json.encode(event) ) if err then @@ -98,16 +421,11 @@ local function update_event(access_token, calendar_id, event_id, task) summary = task.description, start = { date = task.due }, ['end'] = { date = next_day(task.due or '') }, - transparency = 'transparent', } - local _, err = oauth.curl_request( + local _, err = curl_request( 'PATCH', - BASE_URL - .. '/calendars/' - .. oauth.url_encode(calendar_id) - .. '/events/' - .. oauth.url_encode(event_id), - oauth.auth_headers(access_token), + BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id), + auth_headers(access_token), vim.json.encode(event) ) return err @@ -118,165 +436,81 @@ end ---@param event_id string ---@return string? err local function delete_event(access_token, calendar_id, event_id) - local _, err = oauth.curl_request( + local _, err = curl_request( 'DELETE', - BASE_URL - .. '/calendars/' - .. oauth.url_encode(calendar_id) - .. '/events/' - .. oauth.url_encode(event_id), - oauth.auth_headers(access_token) + BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id), + auth_headers(access_token) ) return err end ----@return boolean -local function allow_remote_delete() - local cfg = config.get() - local sync = cfg.sync or {} - local per = (sync.gcal or {}) --[[@as pending.GcalConfig]] - if per.remote_delete ~= nil then - return per.remote_delete == true +function M.sync() + local access_token = get_access_token() + if not access_token then + return end - return sync.remote_delete == true -end ----@param task pending.Task ----@param extra table ----@param now_ts string -local function unlink_remote(task, extra, now_ts) - extra['_gcal_event_id'] = nil - extra['_gcal_calendar_id'] = nil - if next(extra) == nil then - task._extra = nil - else - task._extra = extra + 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 - task.modified = now_ts -end -function M.push() - oauth.with_token(oauth.google_client, 'gcal', function(access_token) - local calendars, cal_err = get_all_calendars(access_token) - if cal_err or not calendars then - log.error(cal_err or 'Failed to fetch calendars.') - return - end + local tasks = store.tasks() + local created, updated, deleted = 0, 0, 0 - local s = require('pending').store() - local now_ts = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] - local created, updated, deleted, failed = 0, 0, 0, 0 + for _, task in ipairs(tasks) do + local extra = task._extra or {} + local event_id = extra['_gcal_event_id'] --[[@as string?]] - 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?]] + local should_delete = event_id ~= nil + and ( + task.status == 'done' + or task.status == 'deleted' + or (task.status == 'pending' and not task.due) + ) - 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 - if allow_remote_delete() then - local del_err = - delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]]) - if del_err then - log.warn('Failed to delete calendar event: ' .. del_err) - failed = failed + 1 - else - unlink_remote(task, extra, now_ts) - deleted = deleted + 1 - end + 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 else - log.debug( - 'gcal: remote delete skipped (remote_delete disabled) — unlinked task #' .. task.id - ) - unlink_remote(task, extra, now_ts) - deleted = deleted + 1 + task._extra = extra end - elseif task.status == 'pending' and task.due then - local cat = task.category or config.get().default_category - if event_id and cal_id then - local upd_err = update_event(access_token, cal_id, event_id, task) - if upd_err then - log.warn('Failed to update calendar event: ' .. upd_err) - failed = failed + 1 - else - updated = updated + 1 - end - else - local lid, lid_err = find_or_create_calendar(access_token, cat, calendars) - if lid_err or not lid then - log.warn('Failed to create calendar: ' .. (lid_err or 'unknown')) - failed = failed + 1 - else - local new_id, create_err = create_event(access_token, lid, task) - if create_err then - log.warn('Failed to create calendar event: ' .. create_err) - failed = failed + 1 - elseif new_id then - if not task._extra then - task._extra = {} - end - task._extra['_gcal_event_id'] = new_id - task._extra['_gcal_calendar_id'] = lid - task.modified = now_ts - created = created + 1 - end + 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 = {} end + task._extra['_gcal_event_id'] = new_id + task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + created = created + 1 end end end + end - util.finish(s) - log.info('gcal: push ' .. util.fmt_counts({ - { created, 'added' }, - { updated, 'updated' }, - { deleted, 'removed' }, - { failed, 'failed' }, - })) - end) -end - ----@param args? string ----@return nil -function M.auth(args) - if args == 'clear' then - oauth.google_client:clear_tokens() - log.info('gcal: OAuth tokens cleared — run :Pending auth gcal to re-authenticate.') - elseif args == 'reset' then - oauth.google_client:_wipe() - log.info( - 'gcal: OAuth tokens and credentials cleared — run :Pending auth gcal to set up from scratch.' + store.save() + vim.notify( + string.format( + 'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)', + created, + updated, + deleted ) - else - local creds = oauth.google_client:resolve_credentials() - if creds.client_id == oauth.BUNDLED_CLIENT_ID then - oauth.google_client:setup() - else - oauth.google_client:auth() - end - end -end - ----@return string[] -function M.auth_complete() - return { 'clear', 'reset' } -end - ----@return nil -function M.health() - oauth.health(M.name) - local tokens = oauth.google_client:load_tokens() - if tokens and tokens.refresh_token then - vim.health.ok('gcal tokens found') - else - vim.health.info('no gcal tokens — run :Pending auth gcal') - end + ) end return M diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua deleted file mode 100644 index 272bed6..0000000 --- a/lua/pending/sync/gtasks.lua +++ /dev/null @@ -1,544 +0,0 @@ -local config = require('pending.config') -local log = require('pending.log') -local oauth = require('pending.sync.oauth') -local util = require('pending.sync.util') - -local M = {} - -M.name = 'gtasks' - -local BASE_URL = 'https://tasks.googleapis.com/tasks/v1' - ----@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 - ----@return boolean -local function allow_remote_delete() - local cfg = config.get() - local sync = cfg.sync or {} - local per = (sync.gtasks or {}) --[[@as pending.GtasksConfig]] - if per.remote_delete ~= nil then - return per.remote_delete == true - end - return sync.remote_delete == true -end - ----@param task pending.Task ----@param now_ts string -local function unlink_remote(task, now_ts) - task._extra['_gtasks_task_id'] = nil - task._extra['_gtasks_list_id'] = nil - task._extra['_gtasks_synced_at'] = nil - if next(task._extra) == nil then - task._extra = nil - end - task.modified = now_ts -end - ----@param 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 ----@return integer failed -local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) - local created, updated, deleted, failed = 0, 0, 0, 0 - for _, task in ipairs(s:tasks()) do - local extra = task._extra or {} - local gtid = extra['_gtasks_task_id'] --[[@as string?]] - local list_id = extra['_gtasks_list_id'] --[[@as string?]] - - if task.status == 'deleted' and gtid and list_id then - if allow_remote_delete() then - local err = delete_gtask(access_token, list_id, gtid) - if err then - log.warn('Failed to delete remote task: ' .. err) - failed = failed + 1 - else - unlink_remote(task, now_ts) - deleted = deleted + 1 - end - else - log.debug( - 'gtasks: remote delete skipped (remote_delete disabled) — unlinked task #' .. task.id - ) - unlink_remote(task, now_ts) - deleted = deleted + 1 - end - elseif task.status ~= 'deleted' then - if gtid and list_id then - local synced_at = extra['_gtasks_synced_at'] --[[@as string?]] - if not synced_at or task.modified > synced_at then - local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task)) - if err then - log.warn('Failed to update remote task: ' .. err) - failed = failed + 1 - else - task._extra = task._extra or {} - task._extra['_gtasks_synced_at'] = now_ts - updated = updated + 1 - end - end - elseif task.status == 'pending' then - local cat = task.category or config.get().default_category - local lid, err = find_or_create_tasklist(access_token, cat, tasklists) - if not err and lid then - local new_id, create_err = create_gtask(access_token, lid, task_to_gtask(task)) - if create_err then - log.warn('Failed to create remote task: ' .. create_err) - failed = failed + 1 - elseif new_id then - if not task._extra then - task._extra = {} - end - task._extra['_gtasks_task_id'] = new_id - task._extra['_gtasks_list_id'] = lid - task._extra['_gtasks_synced_at'] = now_ts - task.modified = now_ts - by_gtasks_id[new_id] = task - created = created + 1 - end - end - end - end - end - return created, updated, deleted, failed -end - ----@param access_token string ----@param tasklists table ----@param s pending.Store ----@param now_ts string ----@param by_gtasks_id table ----@return integer created ----@return integer updated ----@return integer failed ----@return table seen_remote_ids ----@return table fetched_list_ids -local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) - local created, updated, failed = 0, 0, 0 - ---@type table - local seen_remote_ids = {} - ---@type table - local fetched_list_ids = {} - for list_name, list_id in pairs(tasklists) do - local items, err = list_gtasks(access_token, list_id) - if err then - log.warn('Failed to fetch task list "' .. list_name .. '": ' .. err) - failed = failed + 1 - else - fetched_list_ids[list_id] = true - for _, gtask in ipairs(items or {}) do - seen_remote_ids[gtask.id] = true - local local_task = by_gtasks_id[gtask.id] - if local_task then - local gtask_updated = gtask.updated or '' - local local_modified = local_task.modified or '' - if gtask_updated > local_modified then - local fields = gtask_to_fields(gtask, list_name) - for k, v in pairs(fields) do - local_task[k] = v - end - local_task._extra = local_task._extra or {} - local_task._extra['_gtasks_synced_at'] = now_ts - local_task.modified = now_ts - updated = updated + 1 - end - else - local fields = gtask_to_fields(gtask, list_name) - fields._extra = { - _gtasks_task_id = gtask.id, - _gtasks_list_id = list_id, - _gtasks_synced_at = now_ts, - } - local new_task = s:add(fields) - by_gtasks_id[gtask.id] = new_task - created = created + 1 - end - end - end - end - return created, updated, failed, seen_remote_ids, fetched_list_ids -end - ----@param s pending.Store ----@param seen_remote_ids table ----@param fetched_list_ids table ----@param now_ts string ----@return integer unlinked -local function detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts) - local unlinked = 0 - for _, task in ipairs(s:tasks()) do - local extra = task._extra or {} - local gtid = extra['_gtasks_task_id'] - local list_id = extra['_gtasks_list_id'] - if - task.status ~= 'deleted' - and gtid - and list_id - and fetched_list_ids[list_id] - and not seen_remote_ids[gtid] - then - task._extra['_gtasks_task_id'] = nil - task._extra['_gtasks_list_id'] = nil - task._extra['_gtasks_synced_at'] = nil - if next(task._extra) == nil then - task._extra = nil - end - task.modified = now_ts - unlinked = unlinked + 1 - end - end - return unlinked -end - ----@param access_token string ----@return table? tasklists ----@return pending.Store? s ----@return string? now_ts -local function sync_setup(access_token) - local tasklists, tl_err = get_all_tasklists(access_token) - if tl_err or not tasklists then - log.error(tl_err or 'Failed to fetch task lists.') - return nil, nil, nil - end - local s = require('pending').store() - local now_ts = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] - return tasklists, s, now_ts -end - -function M.push() - oauth.with_token(oauth.google_client, 'gtasks', function(access_token) - local tasklists, s, now_ts = sync_setup(access_token) - if not tasklists then - return - end - ---@cast s pending.Store - ---@cast now_ts string - local by_gtasks_id = build_id_index(s) - local created, updated, deleted, failed = - push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) - util.finish(s) - log.info('gtasks: push ' .. util.fmt_counts({ - { created, 'added' }, - { updated, 'updated' }, - { deleted, 'deleted' }, - { failed, 'failed' }, - })) - end) -end - -function M.pull() - oauth.with_token(oauth.google_client, 'gtasks', function(access_token) - local tasklists, s, now_ts = sync_setup(access_token) - if not tasklists then - return - end - ---@cast s pending.Store - ---@cast now_ts string - local by_gtasks_id = build_id_index(s) - local created, updated, failed, seen_remote_ids, fetched_list_ids = - pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) - local unlinked = detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts) - util.finish(s) - log.info('gtasks: pull ' .. util.fmt_counts({ - { created, 'added' }, - { updated, 'updated' }, - { unlinked, 'unlinked' }, - { failed, 'failed' }, - })) - end) -end - -function M.sync() - oauth.with_token(oauth.google_client, 'gtasks', function(access_token) - local tasklists, s, now_ts = sync_setup(access_token) - if not tasklists then - return - end - ---@cast s pending.Store - ---@cast now_ts string - local by_gtasks_id = build_id_index(s) - local pushed_create, pushed_update, pushed_delete, pushed_failed = - push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) - local pulled_create, pulled_update, pulled_failed, seen_remote_ids, fetched_list_ids = - pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) - local unlinked = detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts) - util.finish(s) - log.info('gtasks: sync push ' .. util.fmt_counts({ - { pushed_create, 'added' }, - { pushed_update, 'updated' }, - { pushed_delete, 'deleted' }, - { pushed_failed, 'failed' }, - }) .. ' | pull ' .. util.fmt_counts({ - { pulled_create, 'added' }, - { pulled_update, 'updated' }, - { unlinked, 'unlinked' }, - { pulled_failed, 'failed' }, - })) - end) -end - -M._due_to_rfc3339 = due_to_rfc3339 -M._rfc3339_to_date = rfc3339_to_date -M._build_notes = build_notes -M._parse_notes = parse_notes -M._task_to_gtask = task_to_gtask -M._gtask_to_fields = gtask_to_fields -M._push_pass = push_pass -M._pull_pass = pull_pass -M._detect_remote_deletions = detect_remote_deletions - ----@param args? string ----@return nil -function M.auth(args) - if args == 'clear' then - oauth.google_client:clear_tokens() - log.info('gtasks: OAuth tokens cleared — run :Pending auth gtasks to re-authenticate.') - elseif args == 'reset' then - oauth.google_client:_wipe() - log.info( - 'gtasks: OAuth tokens and credentials cleared — run :Pending auth gtasks to set up from scratch.' - ) - else - local creds = oauth.google_client:resolve_credentials() - if creds.client_id == oauth.BUNDLED_CLIENT_ID then - oauth.google_client:setup() - else - oauth.google_client:auth() - end - end -end - ----@return string[] -function M.auth_complete() - return { 'clear', 'reset' } -end - ----@return nil -function M.health() - oauth.health(M.name) - local tokens = oauth.google_client:load_tokens() - if tokens and tokens.refresh_token then - vim.health.ok('gtasks tokens found') - else - vim.health.info('no gtasks tokens — run :Pending auth gtasks') - end -end - -return M diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua deleted file mode 100644 index 516a353..0000000 --- a/lua/pending/sync/oauth.lua +++ /dev/null @@ -1,549 +0,0 @@ -local config = require('pending.config') -local log = require('pending.log') - -local TOKEN_URL = 'https://oauth2.googleapis.com/token' -local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' - -local BUNDLED_CLIENT_ID = 'PLACEHOLDER' -local BUNDLED_CLIENT_SECRET = 'PLACEHOLDER' - ----@class pending.OAuthCredentials ----@field client_id string ----@field client_secret string - ----@class pending.OAuthTokens ----@field access_token string ----@field refresh_token string ----@field expires_in? integer ----@field obtained_at? integer - ----@class pending.OAuthClientOpts ----@field name string ----@field scope string ----@field port integer ----@field config_key string - ----@class pending.OAuthClient : pending.OAuthClientOpts -local OAuthClient = {} -OAuthClient.__index = OAuthClient - -local util = require('pending.sync.util') - -local _active_close = nil - ----@class pending.oauth -local M = {} - -M.system = util.system -M.async = util.async - ----@param client pending.OAuthClient ----@param name string ----@param callback fun(access_token: string): nil -function M.with_token(client, name, callback) - util.async(function() - util.with_guard(name, function() - local token = client:get_access_token() - if not token then - require('pending.log').warn(name .. ': Not authenticated — run :Pending auth.') - return - end - callback(token) - end) - end) -end - ----@param str string ----@return string -function M.url_encode(str) - return ( - str:gsub('([^%w%-%.%_%~])', function(c) - return string.format('%%%02X', string.byte(c)) - end) - ) -end - ----@param path string ----@return table? -function M.load_json_file(path) - local f = io.open(path, 'r') - if not f then - return nil - end - local content = f:read('*a') - f:close() - if content == '' then - return nil - end - local ok, decoded = pcall(vim.json.decode, content) - if not ok then - return nil - end - return decoded -end - ----@param path string ----@param data table ----@return boolean -function M.save_json_file(path, data) - local dir = vim.fn.fnamemodify(path, ':h') - if vim.fn.isdirectory(dir) == 0 then - vim.fn.mkdir(dir, 'p') - end - local f = io.open(path, 'w') - if not f then - return false - end - f:write(vim.json.encode(data)) - f:close() - vim.fn.setfperm(path, 'rw-------') - return true -end - ----@param method string ----@param url string ----@param headers? string[] ----@param body? string ----@return table? result ----@return string? err -function M.curl_request(method, url, headers, body) - local args = { 'curl', '-s', '-X', method } - for _, h in ipairs(headers or {}) do - table.insert(args, '-H') - table.insert(args, h) - end - if body then - table.insert(args, '-d') - table.insert(args, body) - end - table.insert(args, url) - local result = M.system(args, { text = true }) - if result.code ~= 0 then - return nil, 'curl failed: ' .. (result.stderr or '') - end - if not result.stdout or result.stdout == '' then - return {}, nil - end - local ok, decoded = pcall(vim.json.decode, result.stdout) - if not ok then - return nil, 'failed to parse response: ' .. result.stdout - end - if decoded.error then - return nil, 'API error: ' .. (decoded.error.message or vim.json.encode(decoded.error)) - end - return decoded, nil -end - ----@param access_token string ----@return string[] -function M.auth_headers(access_token) - return { - 'Authorization: Bearer ' .. access_token, - 'Content-Type: application/json', - } -end - ----@param backend_name string ----@return nil -function M.health(backend_name) - if vim.fn.executable('curl') == 1 then - vim.health.ok('curl found (required for ' .. backend_name .. ' sync)') - else - vim.health.warn('curl not found (needed for ' .. backend_name .. ' sync)') - end -end - ----@return string -function OAuthClient:token_path() - return vim.fn.stdpath('data') .. '/pending/' .. self.name .. '_tokens.json' -end - ----@return pending.OAuthCredentials -function OAuthClient:resolve_credentials() - local cfg = config.get() - local backend_cfg = (cfg.sync and cfg.sync[self.config_key]) or {} - - if backend_cfg.client_id and backend_cfg.client_secret then - return { - client_id = backend_cfg.client_id, - client_secret = backend_cfg.client_secret, - } - end - - local data_dir = vim.fn.stdpath('data') .. '/pending/' - local cred_paths = {} - if backend_cfg.credentials_path then - table.insert(cred_paths, backend_cfg.credentials_path) - end - table.insert(cred_paths, data_dir .. self.name .. '_credentials.json') - table.insert(cred_paths, data_dir .. 'google_credentials.json') - for _, cred_path in ipairs(cred_paths) do - if cred_path then - local creds = M.load_json_file(cred_path) - if creds then - if creds.installed then - creds = creds.installed - end - if creds.client_id and creds.client_secret then - return creds --[[@as pending.OAuthCredentials]] - end - end - end - end - return { - client_id = BUNDLED_CLIENT_ID, - client_secret = BUNDLED_CLIENT_SECRET, - } -end - ----@return pending.OAuthTokens? -function OAuthClient:load_tokens() - return M.load_json_file(self:token_path()) --[[@as pending.OAuthTokens?]] -end - ----@param tokens pending.OAuthTokens ----@return boolean -function OAuthClient:save_tokens(tokens) - return M.save_json_file(self:token_path(), tokens) -end - ----@param creds pending.OAuthCredentials ----@param tokens pending.OAuthTokens ----@return pending.OAuthTokens? -function OAuthClient:refresh_access_token(creds, tokens) - local body = 'client_id=' - .. M.url_encode(creds.client_id) - .. '&client_secret=' - .. M.url_encode(creds.client_secret) - .. '&grant_type=refresh_token' - .. '&refresh_token=' - .. M.url_encode(tokens.refresh_token) - local result = M.system({ - 'curl', - '-s', - '-X', - 'POST', - '-H', - 'Content-Type: application/x-www-form-urlencoded', - '-d', - body, - TOKEN_URL, - }, { text = true }) - if result.code ~= 0 then - return nil - end - local ok, decoded = pcall(vim.json.decode, result.stdout or '') - if not ok or not decoded.access_token then - return nil - end - tokens.access_token = decoded.access_token --[[@as string]] - tokens.expires_in = decoded.expires_in --[[@as integer?]] - tokens.obtained_at = os.time() - self:save_tokens(tokens) - return tokens -end - ----@return string? -function OAuthClient:get_access_token() - local creds = self:resolve_credentials() - local tokens = self:load_tokens() - if not tokens or not tokens.refresh_token then - return nil - end - local now = os.time() - local obtained = tokens.obtained_at or 0 - local expires = tokens.expires_in or 3600 - if now - obtained > expires - 60 then - tokens = self:refresh_access_token(creds, tokens) - if not tokens then - log.error(self.name .. ': Token refresh failed — re-authenticating...') - return nil - end - end - return tokens.access_token -end - ----@return nil -function OAuthClient:setup() - local choice = vim.fn.inputlist({ - self.name .. ' setup:', - '1. Enter client ID and secret', - '2. Load from JSON file path', - }) - vim.cmd.redraw() - - local id, secret - - if choice == 1 then - while true do - id = vim.trim(vim.fn.input(self.name .. ' client ID: ')) - if id == '' then - return - end - if id:match('^%d+%-[%w_]+%.apps%.googleusercontent%.com$') then - break - end - vim.cmd.redraw() - vim.api.nvim_echo({ - { - 'invalid client ID — expected -.apps.googleusercontent.com', - 'ErrorMsg', - }, - }, false, {}) - end - - while true do - secret = vim.trim(vim.fn.inputsecret(self.name .. ' client secret: ')) - if secret == '' then - return - end - if secret:match('^GOCSPX%-') then - break - end - vim.cmd.redraw() - vim.api.nvim_echo( - { { 'invalid client secret — expected GOCSPX-...', 'ErrorMsg' } }, - false, - {} - ) - end - elseif choice == 2 then - local fpath - while true do - fpath = vim.trim(vim.fn.input(self.name .. ' credentials file: ', '', 'file')) - if fpath == '' then - return - end - fpath = vim.fn.expand(fpath) - local creds = M.load_json_file(fpath) - if creds then - if creds.installed then - creds = creds.installed - end - if creds.client_id and creds.client_secret then - id = creds.client_id - secret = creds.client_secret - break - end - end - vim.cmd.redraw() - vim.api.nvim_echo( - { { 'could not read client_id/client_secret from ' .. fpath, 'ErrorMsg' } }, - false, - {} - ) - end - else - return - end - - vim.schedule(function() - local path = vim.fn.stdpath('data') .. '/pending/google_credentials.json' - local ok = M.save_json_file(path, { client_id = id, client_secret = secret }) - if not ok then - log.error(self.name .. ': Failed to save credentials.') - return - end - log.info(self.name .. ': Credentials saved, starting authorization...') - self:auth() - end) -end - ----@param on_complete? fun(): nil ----@return nil -function OAuthClient:auth(on_complete) - if _active_close then - _active_close() - _active_close = nil - end - - local creds = self:resolve_credentials() - if creds.client_id == BUNDLED_CLIENT_ID then - log.error(self.name .. ': No credentials configured — run :Pending auth.') - return - end - local port = self.port - - local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' - local verifier = {} - math.randomseed(vim.uv.hrtime()) - for _ = 1, 64 do - local idx = math.random(1, #verifier_chars) - table.insert(verifier, verifier_chars:sub(idx, idx)) - end - local code_verifier = table.concat(verifier) - - local hex = vim.fn.sha256(code_verifier) - local binary = hex:gsub('..', function(h) - return string.char(tonumber(h, 16)) - end) - local code_challenge = vim.base64.encode(binary):gsub('+', '-'):gsub('/', '_'):gsub('=', '') - - local auth_url = AUTH_URL - .. '?client_id=' - .. M.url_encode(creds.client_id) - .. '&redirect_uri=' - .. M.url_encode('http://127.0.0.1:' .. port) - .. '&response_type=code' - .. '&scope=' - .. M.url_encode(self.scope) - .. '&access_type=offline' - .. '&prompt=select_account%20consent' - .. '&code_challenge=' - .. M.url_encode(code_challenge) - .. '&code_challenge_method=S256' - - local server = vim.uv.new_tcp() - local server_closed = false - local function close_server() - if server_closed then - return - end - server_closed = true - if _active_close == close_server then - _active_close = nil - end - server:close() - end - _active_close = close_server - - local bind_ok, bind_err = pcall(server.bind, server, '127.0.0.1', port) - if not bind_ok or bind_err == nil then - close_server() - log.error(self.name .. ': Port ' .. port .. ' already in use — try again in a moment.') - return - end - - server:listen(1, function(err) - if err then - return - end - local conn = vim.uv.new_tcp() - server:accept(conn) - conn:read_start(function(read_err, data) - if read_err or not data then - conn:close() - close_server() - return - end - local code = data:match('[?&]code=([^&%s]+)') - local response_body = code - and '

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, on_complete) - end) - end - end) - end) - - vim.ui.open(auth_url) - log.info('Opening browser for Google authorization...') - - vim.defer_fn(function() - if not server_closed then - close_server() - log.warn('OAuth callback timed out (120s).') - end - end, 120000) -end - ----@param creds pending.OAuthCredentials ----@param code string ----@param code_verifier string ----@param port integer ----@param on_complete? fun(): nil ----@return nil -function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complete) - local body = 'client_id=' - .. M.url_encode(creds.client_id) - .. '&client_secret=' - .. M.url_encode(creds.client_secret) - .. '&code=' - .. M.url_encode(code) - .. '&code_verifier=' - .. M.url_encode(code_verifier) - .. '&grant_type=authorization_code' - .. '&redirect_uri=' - .. M.url_encode('http://127.0.0.1:' .. port) - - local result = M.system({ - 'curl', - '-s', - '-X', - 'POST', - '-H', - 'Content-Type: application/x-www-form-urlencoded', - '-d', - body, - TOKEN_URL, - }, { text = true }) - - if result.code ~= 0 then - self:clear_tokens() - log.error('Token exchange failed.') - return - end - - local ok, decoded = pcall(vim.json.decode, result.stdout or '') - if not ok or not decoded.access_token then - self:clear_tokens() - log.error('Invalid token response.') - return - end - - decoded.obtained_at = os.time() - self:save_tokens(decoded) - log.info(self.name .. ': Authorized successfully.') - if on_complete then - on_complete() - end -end - ----@return nil -function OAuthClient:_wipe() - os.remove(self:token_path()) - os.remove(vim.fn.stdpath('data') .. '/pending/google_credentials.json') -end - ----@return nil -function OAuthClient:clear_tokens() - if _active_close then - _active_close() - _active_close = nil - end - os.remove(self:token_path()) -end - ----@param opts pending.OAuthClientOpts ----@return pending.OAuthClient -function M.new(opts) - return setmetatable({ - name = opts.name, - scope = opts.scope, - port = opts.port, - config_key = opts.config_key, - }, OAuthClient) -end - -M._BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID -M._BUNDLED_CLIENT_SECRET = BUNDLED_CLIENT_SECRET -M.BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID - -M.google_client = M.new({ - name = 'google', - scope = 'https://www.googleapis.com/auth/tasks' .. ' https://www.googleapis.com/auth/calendar', - port = 18392, - config_key = 'google', -}) - -return M diff --git a/lua/pending/sync/s3.lua b/lua/pending/sync/s3.lua deleted file mode 100644 index 64c4348..0000000 --- a/lua/pending/sync/s3.lua +++ /dev/null @@ -1,470 +0,0 @@ -local log = require('pending.log') -local util = require('pending.sync.util') - -local M = {} - -M.name = 's3' - ----@return pending.S3Config? -local function get_config() - local cfg = require('pending.config').get() - return cfg.sync and cfg.sync.s3 -end - ----@return string[] -local function base_cmd() - local s3cfg = get_config() or {} - local cmd = { 'aws' } - if s3cfg.profile then - table.insert(cmd, '--profile') - table.insert(cmd, s3cfg.profile) - end - if s3cfg.region then - table.insert(cmd, '--region') - table.insert(cmd, s3cfg.region) - end - return cmd -end - ----@param task pending.Task ----@return string -local function ensure_sync_id(task) - if not task._extra then - task._extra = {} - end - local sync_id = task._extra['_s3_sync_id'] - if not sync_id then - local bytes = {} - math.randomseed(vim.uv.hrtime()) - for i = 1, 16 do - bytes[i] = math.random(0, 255) - end - bytes[7] = bit.bor(bit.band(bytes[7], 0x0f), 0x40) - bytes[9] = bit.bor(bit.band(bytes[9], 0x3f), 0x80) - sync_id = string.format( - '%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x', - bytes[1], - bytes[2], - bytes[3], - bytes[4], - bytes[5], - bytes[6], - bytes[7], - bytes[8], - bytes[9], - bytes[10], - bytes[11], - bytes[12], - bytes[13], - bytes[14], - bytes[15], - bytes[16] - ) - task._extra['_s3_sync_id'] = sync_id - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] - end - return sync_id -end - -local function create_bucket() - local name = util.input({ prompt = 'S3 bucket name (pending.nvim): ' }) - if not name then - log.info('s3: bucket creation cancelled') - return - end - if name == '' then - name = 'pending.nvim' - end - - local region_cmd = base_cmd() - vim.list_extend(region_cmd, { 'configure', 'get', 'region' }) - local region_result = util.system(region_cmd, { text = true }) - local default_region = 'us-east-1' - if region_result.code == 0 and region_result.stdout then - local detected = vim.trim(region_result.stdout) - if detected ~= '' then - default_region = detected - end - end - - local region = util.input({ prompt = 'AWS region (' .. default_region .. '): ' }) - if not region or region == '' then - region = default_region - end - - local cmd = base_cmd() - vim.list_extend(cmd, { 's3api', 'create-bucket', '--bucket', name, '--region', region }) - if region ~= 'us-east-1' then - vim.list_extend(cmd, { '--create-bucket-configuration', 'LocationConstraint=' .. region }) - end - - local result = util.system(cmd, { text = true }) - if result.code == 0 then - log.info( - 's3: bucket created. Add to your pending.nvim config:\n sync = { s3 = { bucket = "' - .. name - .. '", region = "' - .. region - .. '" } }' - ) - else - log.error('s3: bucket creation failed — ' .. (result.stderr or 'unknown error')) - end -end - ----@param args? string ----@return nil -function M.auth(args) - if args == 'profile' then - vim.ui.input({ prompt = 'AWS profile name: ' }, function(input) - if not input or input == '' then - local s3cfg = get_config() - if s3cfg and s3cfg.profile then - log.info('s3: current profile: ' .. s3cfg.profile) - else - log.info('s3: no profile configured (using default)') - end - return - end - log.info('s3: set profile in your config: sync = { s3 = { profile = "' .. input .. '" } }') - end) - return - end - - util.async(function() - local cmd = base_cmd() - vim.list_extend(cmd, { 'sts', 'get-caller-identity', '--output', 'json' }) - local result = util.system(cmd, { text = true }) - if result.code == 0 then - local ok, data = pcall(vim.json.decode, result.stdout or '') - if ok and data then - log.info('s3: authenticated as ' .. (data.Arn or data.Account or 'unknown')) - else - log.info('s3: credentials valid') - end - local s3cfg = get_config() - if not s3cfg or not s3cfg.bucket then - create_bucket() - end - else - local stderr = result.stderr or '' - if stderr:find('SSO') or stderr:find('sso') then - log.info('s3: SSO session expired — running login...') - local login_cmd = base_cmd() - vim.list_extend(login_cmd, { 'sso', 'login' }) - local login_result = util.system(login_cmd, { text = true }) - if login_result.code == 0 then - log.info('s3: SSO login successful') - else - log.error('s3: SSO login failed — ' .. (login_result.stderr or '')) - end - elseif - stderr:find('Unable to locate credentials') or stderr:find('NoCredentialProviders') - then - log.error('s3: no AWS credentials configured. See :h pending-s3') - else - log.error('s3: ' .. stderr) - end - end - end) -end - ----@return string[] -function M.auth_complete() - return { 'profile' } -end - -function M.push() - util.async(function() - util.with_guard('s3', function() - local s3cfg = get_config() - if not s3cfg or not s3cfg.bucket then - log.error('s3: bucket is required. Set sync.s3.bucket in config.') - return - end - local key = s3cfg.key or 'pending.json' - local s = require('pending').store() - - for _, task in ipairs(s:tasks()) do - ensure_sync_id(task) - end - - local tmpfile = vim.fn.tempname() .. '.json' - s:save() - - local store = require('pending.store') - local tmp_store = store.new(s.path) - tmp_store:load() - - local f = io.open(s.path, 'r') - if not f then - log.error('s3: failed to read store file') - return - end - local content = f:read('*a') - f:close() - - local tf = io.open(tmpfile, 'w') - if not tf then - log.error('s3: failed to create temp file') - return - end - tf:write(content) - tf:close() - - local cmd = base_cmd() - vim.list_extend(cmd, { 's3', 'cp', tmpfile, 's3://' .. s3cfg.bucket .. '/' .. key }) - local result = util.system(cmd, { text = true }) - os.remove(tmpfile) - - if result.code ~= 0 then - log.error('s3: push failed — ' .. (result.stderr or 'unknown error')) - return - end - - util.finish(s) - log.info('s3: push uploaded to s3://' .. s3cfg.bucket .. '/' .. key) - end) - end) -end - -function M.pull() - util.async(function() - util.with_guard('s3', function() - local s3cfg = get_config() - if not s3cfg or not s3cfg.bucket then - log.error('s3: bucket is required. Set sync.s3.bucket in config.') - return - end - local key = s3cfg.key or 'pending.json' - local tmpfile = vim.fn.tempname() .. '.json' - - local cmd = base_cmd() - vim.list_extend(cmd, { 's3', 'cp', 's3://' .. s3cfg.bucket .. '/' .. key, tmpfile }) - local result = util.system(cmd, { text = true }) - - if result.code ~= 0 then - os.remove(tmpfile) - log.error('s3: pull failed — ' .. (result.stderr or 'unknown error')) - return - end - - local store = require('pending.store') - local s_remote = store.new(tmpfile) - local load_ok = pcall(function() - s_remote:load() - end) - if not load_ok then - os.remove(tmpfile) - log.error('s3: pull failed — could not parse remote store') - return - end - - local s = require('pending').store() - local created, updated, unchanged = 0, 0, 0 - - local local_by_sync_id = {} - for _, task in ipairs(s:tasks()) do - local extra = task._extra or {} - local sid = extra['_s3_sync_id'] - if sid then - local_by_sync_id[sid] = task - end - end - - for _, remote_task in ipairs(s_remote:tasks()) do - local r_extra = remote_task._extra or {} - local r_sid = r_extra['_s3_sync_id'] - if not r_sid then - goto continue - end - - local local_task = local_by_sync_id[r_sid] - if local_task then - local r_mod = remote_task.modified or '' - local l_mod = local_task.modified or '' - if r_mod > l_mod then - local_task.description = remote_task.description - local_task.status = remote_task.status - local_task.category = remote_task.category - local_task.priority = remote_task.priority - local_task.due = remote_task.due - local_task.recur = remote_task.recur - local_task.recur_mode = remote_task.recur_mode - local_task['end'] = remote_task['end'] - local_task._extra = local_task._extra or {} - local_task._extra['_s3_sync_id'] = r_sid - local_task.modified = remote_task.modified - updated = updated + 1 - else - unchanged = unchanged + 1 - end - else - s:add({ - description = remote_task.description, - status = remote_task.status, - category = remote_task.category, - priority = remote_task.priority, - due = remote_task.due, - recur = remote_task.recur, - recur_mode = remote_task.recur_mode, - _extra = { _s3_sync_id = r_sid }, - }) - created = created + 1 - end - - ::continue:: - end - - os.remove(tmpfile) - util.finish(s) - log.info('s3: pull ' .. util.fmt_counts({ - { created, 'added' }, - { updated, 'updated' }, - { unchanged, 'unchanged' }, - })) - end) - end) -end - -function M.sync() - util.async(function() - util.with_guard('s3', function() - local s3cfg = get_config() - if not s3cfg or not s3cfg.bucket then - log.error('s3: bucket is required. Set sync.s3.bucket in config.') - return - end - local key = s3cfg.key or 'pending.json' - local tmpfile = vim.fn.tempname() .. '.json' - - local cmd = base_cmd() - vim.list_extend(cmd, { 's3', 'cp', 's3://' .. s3cfg.bucket .. '/' .. key, tmpfile }) - local result = util.system(cmd, { text = true }) - - local s = require('pending').store() - local created, updated = 0, 0 - - if result.code == 0 then - local store = require('pending.store') - local s_remote = store.new(tmpfile) - local load_ok = pcall(function() - s_remote:load() - end) - - if load_ok then - local local_by_sync_id = {} - for _, task in ipairs(s:tasks()) do - local extra = task._extra or {} - local sid = extra['_s3_sync_id'] - if sid then - local_by_sync_id[sid] = task - end - end - - for _, remote_task in ipairs(s_remote:tasks()) do - local r_extra = remote_task._extra or {} - local r_sid = r_extra['_s3_sync_id'] - if not r_sid then - goto continue - end - - local local_task = local_by_sync_id[r_sid] - if local_task then - local r_mod = remote_task.modified or '' - local l_mod = local_task.modified or '' - if r_mod > l_mod then - local_task.description = remote_task.description - local_task.status = remote_task.status - local_task.category = remote_task.category - local_task.priority = remote_task.priority - local_task.due = remote_task.due - local_task.recur = remote_task.recur - local_task.recur_mode = remote_task.recur_mode - local_task['end'] = remote_task['end'] - local_task._extra = local_task._extra or {} - local_task._extra['_s3_sync_id'] = r_sid - local_task.modified = remote_task.modified - updated = updated + 1 - end - else - s:add({ - description = remote_task.description, - status = remote_task.status, - category = remote_task.category, - priority = remote_task.priority, - due = remote_task.due, - recur = remote_task.recur, - recur_mode = remote_task.recur_mode, - _extra = { _s3_sync_id = r_sid }, - }) - created = created + 1 - end - - ::continue:: - end - end - end - os.remove(tmpfile) - - for _, task in ipairs(s:tasks()) do - ensure_sync_id(task) - end - s:save() - - local f = io.open(s.path, 'r') - if not f then - log.error('s3: sync failed — could not read store file') - return - end - local content = f:read('*a') - f:close() - - local push_tmpfile = vim.fn.tempname() .. '.json' - local tf = io.open(push_tmpfile, 'w') - if not tf then - log.error('s3: sync failed — could not create temp file') - return - end - tf:write(content) - tf:close() - - local push_cmd = base_cmd() - vim.list_extend(push_cmd, { 's3', 'cp', push_tmpfile, 's3://' .. s3cfg.bucket .. '/' .. key }) - local push_result = util.system(push_cmd, { text = true }) - os.remove(push_tmpfile) - - if push_result.code ~= 0 then - log.error('s3: sync push failed — ' .. (push_result.stderr or 'unknown error')) - util.finish(s) - return - end - - util.finish(s) - log.info('s3: sync ' .. util.fmt_counts({ - { created, 'added' }, - { updated, 'updated' }, - }) .. ' | push uploaded') - end) - end) -end - ----@return nil -function M.health() - if vim.fn.executable('aws') == 1 then - vim.health.ok('aws CLI found') - else - vim.health.error('aws CLI not found (required for S3 sync)') - end - - local s3cfg = get_config() - if s3cfg and s3cfg.bucket then - vim.health.ok('S3 bucket configured: ' .. s3cfg.bucket) - else - vim.health.warn('S3 bucket not configured — set sync.s3.bucket') - end -end - -M._ensure_sync_id = ensure_sync_id - -return M diff --git a/lua/pending/sync/util.lua b/lua/pending/sync/util.lua deleted file mode 100644 index 269acdf..0000000 --- a/lua/pending/sync/util.lua +++ /dev/null @@ -1,98 +0,0 @@ -local log = require('pending.log') - ----@class pending.SystemResult ----@field code integer ----@field stdout string ----@field stderr string - ----@class pending.CountPart ----@field [1] integer ----@field [2] string - ----@class pending.sync.util -local M = {} - -local _sync_in_flight = false - ----@param fn fun(): nil -function M.async(fn) - coroutine.resume(coroutine.create(fn)) -end - ----@param args string[] ----@param opts? table ----@return pending.SystemResult -function M.system(args, opts) - local co = coroutine.running() - if not co then - return vim.system(args, opts or {}):wait() --[[@as pending.SystemResult]] - 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 opts? {prompt?: string, default?: string} ----@return string? -function M.input(opts) - local co = coroutine.running() - if not co then - error('util.input() must be called inside a coroutine') - end - vim.ui.input(opts or {}, function(result) - vim.schedule(function() - coroutine.resume(co, result) - end) - end) - return coroutine.yield() --[[@as string?]] -end - ----@param name string ----@param fn fun(): nil -function M.with_guard(name, fn) - if _sync_in_flight then - log.warn(name .. ': Sync already in progress — please wait.') - return - end - _sync_in_flight = true - local ok, err = pcall(fn) - _sync_in_flight = false - if not ok then - log.error(name .. ': ' .. tostring(err)) - end -end - ----@return boolean -function M.sync_in_flight() - return _sync_in_flight -end - ----@param s pending.Store -function M.finish(s) - 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 -end - ----@param parts pending.CountPart[] ----@return string -function M.fmt_counts(parts) - local items = {} - for _, p in ipairs(parts) do - if p[1] > 0 then - table.insert(items, p[1] .. ' ' .. p[2]) - end - end - if #items == 0 then - return 'nothing to do' - end - return table.concat(items, ' | ') -end - -return M diff --git a/lua/pending/textobj.lua b/lua/pending/textobj.lua deleted file mode 100644 index 887ef8f..0000000 --- a/lua/pending/textobj.lua +++ /dev/null @@ -1,383 +0,0 @@ -local buffer = require('pending.buffer') -local config = require('pending.config') -local log = require('pending.log') - ----@class pending.textobj -local M = {} - ----@param ... any ----@return nil -local function dbg(...) - log.debug(string.format(...)) -end - ----@param lnum integer ----@param meta pending.LineMeta[] ----@return string -local function get_line_from_buf(lnum, meta) - local _ = meta - local bufnr = buffer.bufnr() - if not bufnr then - return '' - end - local lines = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, false) - return lines[1] or '' -end - ----@param line string ----@return integer start_col ----@return integer end_col -function M.inner_task_range(line) - local prefix_end = line:find('/') and select(2, line:find('^/%d+/%- %[.%] ')) - if not prefix_end then - prefix_end = select(2, line:find('^%- %[.%] ')) or 0 - end - local start_col = prefix_end + 1 - - local dk = config.get().date_syntax or 'due' - local rk = config.get().recur_syntax or 'rec' - local dk_pat = '^' .. vim.pesc(dk) .. ':%S+$' - local rk_pat = '^' .. vim.pesc(rk) .. ':%S+$' - - local rest = line:sub(start_col) - local words = {} - for word in rest:gmatch('%S+') do - table.insert(words, word) - end - - local i = #words - while i >= 1 do - local word = words[i] - if word:match(dk_pat) or word:match('^cat:%S+$') or word:match(rk_pat) then - i = i - 1 - else - break - end - end - - if i < 1 then - return start_col, start_col - end - - local desc = table.concat(words, ' ', 1, i) - local end_col = start_col + #desc - 1 - return start_col, end_col -end - ----@param row integer ----@param meta pending.LineMeta[] ----@return integer? header_row ----@return integer? last_row -function M.category_bounds(row, meta) - if not meta or #meta == 0 then - return nil, nil - end - - local header_row = nil - local m = meta[row] - if not m then - return nil, nil - end - - if m.type == 'header' then - header_row = row - else - for r = row, 1, -1 do - if meta[r] and meta[r].type == 'header' then - header_row = r - break - end - end - end - - if not header_row then - return nil, nil - end - - local last_row = header_row - local total = #meta - for r = header_row + 1, total do - if meta[r].type == 'header' then - break - end - last_row = r - end - - return header_row, last_row -end - ----@param count integer ----@return nil -function M.a_task(count) - local meta = buffer.meta() - if not meta or #meta == 0 then - return - end - local row = vim.api.nvim_win_get_cursor(0)[1] - local m = meta[row] - if not m or m.type ~= 'task' then - return - end - - local start_row = row - local end_row = row - count = math.max(1, count) - for _ = 2, count do - local next_row = end_row + 1 - if next_row > #meta then - break - end - if meta[next_row] and meta[next_row].type == 'task' then - end_row = next_row - else - break - end - end - - vim.cmd('normal! ' .. start_row .. 'GV' .. end_row .. 'G') -end - ----@param count integer ----@return nil -function M.a_task_visual(count) - vim.cmd('normal! \27') - M.a_task(count) -end - ----@param count integer ----@return nil -function M.i_task(count) - local _ = count - local meta = buffer.meta() - if not meta or #meta == 0 then - return - end - local row = vim.api.nvim_win_get_cursor(0)[1] - local m = meta[row] - if not m or m.type ~= 'task' then - return - end - - local line = get_line_from_buf(row, meta) - local start_col, end_col = M.inner_task_range(line) - if start_col > end_col then - return - end - - vim.api.nvim_win_set_cursor(0, { row, start_col - 1 }) - vim.cmd('normal! v') - vim.api.nvim_win_set_cursor(0, { row, end_col - 1 }) -end - ----@param count integer ----@return nil -function M.i_task_visual(count) - vim.cmd('normal! \27') - M.i_task(count) -end - ----@param count integer ----@return nil -function M.a_category(count) - local _ = count - local meta = buffer.meta() - if not meta or #meta == 0 then - return - end - local view = buffer.current_view_name() - if view == 'priority' then - return - end - - local row = vim.api.nvim_win_get_cursor(0)[1] - local header_row, last_row = M.category_bounds(row, meta) - if not header_row or not last_row then - return - end - - local start_row = header_row - if header_row > 1 and meta[header_row - 1] and meta[header_row - 1].type == 'blank' then - start_row = header_row - 1 - end - local end_row = last_row - if last_row < #meta and meta[last_row + 1] and meta[last_row + 1].type == 'blank' then - end_row = last_row + 1 - end - - vim.cmd('normal! ' .. start_row .. 'GV' .. end_row .. 'G') -end - ----@param count integer ----@return nil -function M.a_category_visual(count) - vim.cmd('normal! \27') - M.a_category(count) -end - ----@param count integer ----@return nil -function M.i_category(count) - local _ = count - local meta = buffer.meta() - if not meta or #meta == 0 then - return - end - local view = buffer.current_view_name() - if view == 'priority' then - return - end - - local row = vim.api.nvim_win_get_cursor(0)[1] - local header_row, last_row = M.category_bounds(row, meta) - if not header_row or not last_row then - return - end - - local first_task = nil - local last_task = nil - for r = header_row + 1, last_row do - if meta[r] and meta[r].type == 'task' then - if not first_task then - first_task = r - end - last_task = r - end - end - - if not first_task or not last_task then - return - end - - vim.cmd('normal! ' .. first_task .. 'GV' .. last_task .. 'G') -end - ----@param count integer ----@return nil -function M.i_category_visual(count) - vim.cmd('normal! \27') - M.i_category(count) -end - ----@param count integer ----@return nil -function M.next_header(count) - local meta = buffer.meta() - if not meta or #meta == 0 then - return - end - local view = buffer.current_view_name() - if view == 'priority' then - return - end - - local row = vim.api.nvim_win_get_cursor(0)[1] - dbg('next_header: cursor=%d, meta_len=%d, view=%s', row, #meta, view or 'nil') - local found = 0 - count = math.max(1, count) - for r = row + 1, #meta do - if meta[r] and meta[r].type == 'header' then - found = found + 1 - dbg( - 'next_header: found header at row=%d, cat=%s, found=%d/%d', - r, - meta[r].category or '?', - found, - count - ) - if found == count then - vim.api.nvim_win_set_cursor(0, { r, 0 }) - dbg('next_header: cursor set to row=%d, actual=%d', r, vim.api.nvim_win_get_cursor(0)[1]) - return - end - else - dbg('next_header: row=%d type=%s', r, meta[r] and meta[r].type or 'nil') - end - end - dbg('next_header: no header found after row=%d', row) -end - ----@param count integer ----@return nil -function M.prev_header(count) - local meta = buffer.meta() - if not meta or #meta == 0 then - return - end - local view = buffer.current_view_name() - if view == 'priority' then - return - end - - local row = vim.api.nvim_win_get_cursor(0)[1] - dbg('prev_header: cursor=%d, meta_len=%d', row, #meta) - local found = 0 - count = math.max(1, count) - for r = row - 1, 1, -1 do - if meta[r] and meta[r].type == 'header' then - found = found + 1 - dbg( - 'prev_header: found header at row=%d, cat=%s, found=%d/%d', - r, - meta[r].category or '?', - found, - count - ) - if found == count then - vim.api.nvim_win_set_cursor(0, { r, 0 }) - return - end - end - end -end - ----@param count integer ----@return nil -function M.next_task(count) - local meta = buffer.meta() - if not meta or #meta == 0 then - return - end - - local row = vim.api.nvim_win_get_cursor(0)[1] - dbg('next_task: cursor=%d, meta_len=%d', row, #meta) - local found = 0 - count = math.max(1, count) - for r = row + 1, #meta do - if meta[r] and meta[r].type == 'task' then - found = found + 1 - if found == count then - dbg('next_task: jumping to row=%d', r) - vim.api.nvim_win_set_cursor(0, { r, 0 }) - return - end - end - end - dbg('next_task: no task found after row=%d', row) -end - ----@param count integer ----@return nil -function M.prev_task(count) - local meta = buffer.meta() - if not meta or #meta == 0 then - return - end - - local row = vim.api.nvim_win_get_cursor(0)[1] - dbg('prev_task: cursor=%d, meta_len=%d', row, #meta) - local found = 0 - count = math.max(1, count) - for r = row - 1, 1, -1 do - if meta[r] and meta[r].type == 'task' then - found = found + 1 - if found == count then - dbg('prev_task: jumping to row=%d', r) - vim.api.nvim_win_set_cursor(0, { r, 0 }) - return - end - end - end - dbg('prev_task: no task found before row=%d', row) -end - -return M diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 8d4bda5..7bcfaca 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -1,8 +1,7 @@ local config = require('pending.config') -local parse = require('pending.parse') ---@class pending.LineMeta ----@field type 'task'|'header'|'blank'|'filter' +---@field type 'task'|'header'|'blank' ---@field id? integer ---@field due? string ---@field raw_due? string @@ -11,7 +10,6 @@ local parse = require('pending.parse') ---@field overdue? boolean ---@field show_category? boolean ---@field priority? integer ----@field recur? string ---@class pending.views local M = {} @@ -22,10 +20,7 @@ local function format_due(due) if not due then return nil end - 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 + local y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') if not y then return due end @@ -34,39 +29,12 @@ local function format_due(due) month = tonumber(m) --[[@as integer]], day = tonumber(d) --[[@as integer]], }) - local formatted = os.date(config.get().date_format, t) --[[@as string]] - if hh then - formatted = formatted .. ' ' .. hh .. ':' .. mm - end - return formatted -end - ----@type table -local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3 } - ----@param task pending.Task ----@return string -local function state_char(task) - if task.status == 'done' then - return 'x' - elseif task.status == 'wip' then - return '>' - elseif task.status == 'blocked' then - return '=' - elseif task.priority > 0 then - return '!' - end - return ' ' + return os.date(config.get().date_format, t) --[[@as string]] end ---@param tasks pending.Task[] local function sort_tasks(tasks) table.sort(tasks, function(a, b) - local ra = status_rank[a.status] or 1 - local rb = status_rank[b.status] or 1 - if ra ~= rb then - return ra < rb - end if a.priority ~= b.priority then return a.priority > b.priority end @@ -80,11 +48,6 @@ end ---@param tasks pending.Task[] local function sort_tasks_priority(tasks) table.sort(tasks, function(a, b) - local ra = status_rank[a.status] or 1 - local rb = status_rank[b.status] or 1 - if ra ~= rb then - return ra < rb - end if a.priority ~= b.priority then return a.priority > b.priority end @@ -110,6 +73,7 @@ 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 = {} @@ -123,14 +87,14 @@ function M.category_view(tasks) by_cat[cat] = {} done_by_cat[cat] = {} end - if task.status == 'done' or task.status == 'deleted' then + if task.status == 'done' then table.insert(done_by_cat[cat], task) else table.insert(by_cat[cat], task) end end - local cfg_order = config.get().view.category.order + local cfg_order = config.get().category_order if cfg_order and #cfg_order > 0 then local ordered = {} local seen = {} @@ -161,7 +125,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 = {} @@ -174,7 +138,7 @@ function M.category_view(tasks) for _, task in ipairs(all) do local prefix = '/' .. task.id .. '/' - local state = state_char(task) + local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ') local line = prefix .. '- [' .. state .. '] ' .. task.description table.insert(lines, line) table.insert(meta, { @@ -184,9 +148,7 @@ function M.category_view(tasks) raw_due = task.due, status = task.status, category = cat, - priority = task.priority, - overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, - recur = task.recur, + overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil, }) end end @@ -198,6 +160,7 @@ 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 = {} @@ -235,10 +198,8 @@ function M.priority_view(tasks) raw_due = task.due, status = task.status, category = task.category, - priority = task.priority, - overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, + overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil, show_category = true, - recur = task.recur, }) end diff --git a/plugin/pending.lua b/plugin/pending.lua index 084f162..465ee65 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -3,274 +3,16 @@ 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 pending = require('pending') - local subcmds = { 'add', 'archive', 'auth', 'done', 'due', 'edit', 'filter', 'undo' } - for _, b in ipairs(pending.sync_backends()) do - table.insert(subcmds, b) - end - table.sort(subcmds) + local subcmds = { 'add', 'sync', 'archive', 'due', 'undo' } if not cmd_line:match('^Pending%s+%S') then - 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', 'wip', 'blocked' } - 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+archive%s') then - return filter_candidates(arg_lead, { '7d', '2w', '30d', '3m', '6m', '1y' }) - end - if cmd_line:match('^Pending%s+done%s') then - local store = require('pending.store') - local s = store.new(store.resolve_path()) - s:load() - local ids = {} - for _, task in ipairs(s:active_tasks()) do - table.insert(ids, tostring(task.id)) - end - return filter_candidates(arg_lead, ids) - end - if cmd_line:match('^Pending%s+edit') then - return complete_edit(arg_lead, cmd_line) - end - if cmd_line:match('^Pending%s+auth') then - local after_auth = cmd_line:match('^Pending%s+auth%s+(.*)') or '' - local parts = {} - for w in after_auth:gmatch('%S+') do - table.insert(parts, w) - end - local trailing = after_auth:match('%s$') - if #parts == 0 or (#parts == 1 and not trailing) then - local auth_names = {} - for _, b in ipairs(pending.sync_backends()) do - local ok, mod = pcall(require, 'pending.sync.' .. b) - if ok and type(mod.auth) == 'function' then - table.insert(auth_names, b) - end - end - return filter_candidates(arg_lead, auth_names) - end - local backend_name = parts[1] - if #parts == 1 or (#parts == 2 and not trailing) then - local ok, mod = pcall(require, 'pending.sync.' .. backend_name) - if ok and type(mod.auth_complete) == 'function' then - return filter_candidates(arg_lead, mod.auth_complete()) - end - return {} - end - return {} - end - local backend_set = pending.sync_backend_set() - local matched_backend = cmd_line:match('^Pending%s+(%S+)') - if matched_backend and backend_set[matched_backend] then - local after_backend = cmd_line:match('^Pending%s+%S+%s+(.*)') - if not after_backend then - return {} - end - local ok, mod = pcall(require, 'pending.sync.' .. matched_backend) - if not ok then - return {} - end - local actions = {} - for k, v in pairs(mod) do - if - type(v) == 'function' - and k:sub(1, 1) ~= '_' - and k ~= 'health' - and k ~= 'auth' - and k ~= 'auth_complete' - then - table.insert(actions, k) - end - end - table.sort(actions) - return filter_candidates(arg_lead, actions) + return vim.tbl_filter(function(s) + return s:find(arg_lead, 1, true) == 1 + end, subcmds) end return {} end, @@ -280,10 +22,6 @@ 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) @@ -299,97 +37,3 @@ 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-category)', function() - require('pending').prompt_category() -end) - -vim.keymap.set('n', '(pending-recur)', function() - require('pending').prompt_recur() -end) - -vim.keymap.set('n', '(pending-move-down)', function() - require('pending').move_task('down') -end) - -vim.keymap.set('n', '(pending-move-up)', function() - require('pending').move_task('up') -end) - -vim.keymap.set('n', '(pending-wip)', function() - require('pending').toggle_status('wip') -end) - -vim.keymap.set('n', '(pending-blocked)', function() - require('pending').toggle_status('blocked') -end) - -vim.keymap.set('n', '(pending-priority-up)', function() - require('pending').increment_priority() -end) - -vim.keymap.set('n', '(pending-priority-down)', function() - require('pending').decrement_priority() -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 deleted file mode 100755 index 854fe09..0000000 --- a/scripts/ci.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/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 525b12e..df1a912 100644 --- a/spec/archive_spec.lua +++ b/spec/archive_spec.lua @@ -1,224 +1,87 @@ require('spec.helpers') local config = require('pending.config') +local store = require('pending.store') describe('archive', function() local tmpdir - local pending - local ui_input_orig + local pending = require('pending') before_each(function() tmpdir = vim.fn.tempname() vim.fn.mkdir(tmpdir, 'p') vim.g.pending = { data_path = tmpdir .. '/tasks.json' } config.reset() - package.loaded['pending'] = nil - pending = require('pending') - pending.store():load() - ui_input_orig = vim.ui.input + store.unload() + store.load() end) after_each(function() - vim.ui.input = ui_input_orig vim.fn.delete(tmpdir, 'rf') vim.g.pending = nil config.reset() - package.loaded['pending'] = nil end) - local function auto_confirm_y() - vim.ui.input = function(_, on_confirm) - on_confirm('y') - end - end - - local function auto_confirm_n() - vim.ui.input = function(_, on_confirm) - on_confirm('n') - end - end - it('removes done tasks completed more than 30 days ago', function() - auto_confirm_y() - local s = pending.store() - local t = s:add({ description = 'Old done task' }) - s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + local t = store.add({ description = 'Old done task' }) + store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) pending.archive() - assert.are.equal(0, #s:active_tasks()) + assert.are.equal(0, #store.active_tasks()) end) it('keeps done tasks completed fewer than 30 days ago', function() - auto_confirm_y() - local s = pending.store() local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) - local t = s:add({ description = 'Recent done task' }) - s:update(t.id, { status = 'done', ['end'] = recent_end }) + local t = store.add({ description = 'Recent done task' }) + store.update(t.id, { status = 'done', ['end'] = recent_end }) pending.archive() - local active = s:active_tasks() + local active = store.active_tasks() assert.are.equal(1, #active) assert.are.equal('Recent done task', active[1].description) end) - it('respects duration string 7d', function() - auto_confirm_y() - local s = pending.store() + it('respects a custom day count', function() local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400)) - local t = s:add({ description = 'Old for 7 days' }) - s:update(t.id, { status = 'done', ['end'] = eight_days_ago }) - pending.archive('7d') - assert.are.equal(0, #s:active_tasks()) + local t = store.add({ description = 'Old for 7 days' }) + store.update(t.id, { status = 'done', ['end'] = eight_days_ago }) + pending.archive(7) + assert.are.equal(0, #store.active_tasks()) end) - it('respects duration string 2w', function() - auto_confirm_y() - local s = pending.store() - local fifteen_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (15 * 86400)) - local t = s:add({ description = 'Old for 2 weeks' }) - s:update(t.id, { status = 'done', ['end'] = fifteen_days_ago }) - pending.archive('2w') - assert.are.equal(0, #s:active_tasks()) - end) - - it('respects duration string 2m', function() - auto_confirm_y() - local s = pending.store() - local t = s:add({ description = 'Old for 2 months' }) - s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) - pending.archive('2m') - assert.are.equal(0, #s:active_tasks()) - end) - - it('respects bare integer as days (backwards compat)', function() - auto_confirm_y() - local s = pending.store() - local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400)) - 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, #s:active_tasks()) - end) - - it('keeps tasks within the custom duration cutoff', function() - auto_confirm_y() - local s = pending.store() + it('keeps tasks within the custom day cutoff', function() local five_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) - local t = s:add({ description = 'Recent for 7 days' }) - s:update(t.id, { status = 'done', ['end'] = five_days_ago }) - pending.archive('7d') - local active = s:active_tasks() + local t = store.add({ description = 'Recent for 7 days' }) + store.update(t.id, { status = 'done', ['end'] = five_days_ago }) + pending.archive(7) + local active = store.active_tasks() assert.are.equal(1, #active) end) - it('errors on invalid duration input', function() - local messages = {} - local orig_notify = vim.notify - vim.notify = function(msg, ...) - table.insert(messages, msg) - return orig_notify(msg, ...) - end - - local s = pending.store() - local t = s:add({ description = 'Task' }) - s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) - pending.archive('xyz') - - vim.notify = orig_notify - assert.are.equal(1, #s:tasks()) - - local found = false - for _, msg in ipairs(messages) do - if msg:find('Invalid duration') then - found = true - break - end - end - assert.is_true(found) - end) - it('never archives pending tasks regardless of age', function() - auto_confirm_y() - local s = pending.store() - s:add({ description = 'Still pending' }) + store.add({ description = 'Still pending' }) pending.archive() - local active = s:active_tasks() + local active = store.active_tasks() assert.are.equal(1, #active) assert.are.equal('pending', active[1].status) end) it('removes deleted tasks past the cutoff', function() - auto_confirm_y() - local s = pending.store() - local t = s:add({ description = 'Old deleted task' }) - s:update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' }) + local t = store.add({ description = 'Old deleted task' }) + store.update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' }) pending.archive() - local all = s:tasks() + local all = store.tasks() assert.are.equal(0, #all) end) it('keeps deleted tasks within the cutoff', function() - auto_confirm_y() - local s = pending.store() local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) - local t = s:add({ description = 'Recent deleted' }) - s:update(t.id, { status = 'deleted', ['end'] = recent_end }) + local t = store.add({ description = 'Recent deleted' }) + store.update(t.id, { status = 'deleted', ['end'] = recent_end }) pending.archive() - local all = s:tasks() + local all = store.tasks() assert.are.equal(1, #all) end) - it('skips confirmation and reports when no tasks match', function() - local input_called = false - vim.ui.input = function() - input_called = true - end - - local messages = {} - local orig_notify = vim.notify - vim.notify = function(msg, ...) - table.insert(messages, msg) - return orig_notify(msg, ...) - end - - local s = pending.store() - s:add({ description = 'Still pending' }) - pending.archive() - - vim.notify = orig_notify - assert.is_false(input_called) - - local found = false - for _, msg in ipairs(messages) do - if msg:find('No tasks to archive') then - found = true - break - end - end - assert.is_true(found) - end) - - it('does not archive when user declines confirmation', function() - auto_confirm_n() - 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(1, #s:tasks()) - end) - - it('does not archive when user cancels confirmation (nil)', function() - vim.ui.input = function(_, on_confirm) - on_confirm(nil) - end - 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(1, #s:tasks()) - end) - it('reports the correct count in vim.notify', function() - auto_confirm_y() - local s = pending.store() local messages = {} local orig_notify = vim.notify vim.notify = function(msg, ...) @@ -226,11 +89,11 @@ describe('archive', function() return orig_notify(msg, ...) end - 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' }) + 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' }) pending.archive() @@ -246,19 +109,17 @@ describe('archive', function() assert.is_true(found) end) - it('leaves only kept tasks in store after archive', function() - auto_confirm_y() - local s = pending.store() - local t1 = s:add({ description = 'Old done' }) - s:add({ description = 'Keep pending' }) + 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 recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) - 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 }) + 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 }) pending.archive() - local active = s:active_tasks() + local active = store.active_tasks() assert.are.equal(2, #active) local descs = {} for _, task in ipairs(active) do @@ -269,29 +130,11 @@ describe('archive', function() end) it('persists archived tasks to disk after unload/reload', function() - auto_confirm_y() - local s = pending.store() - local t = s:add({ description = 'Archived task' }) - s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + local t = store.add({ description = 'Archived task' }) + store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) pending.archive() - s:load() - assert.are.equal(0, #s:active_tasks()) - end) - - it('includes the duration in the confirmation prompt', function() - local prompt_text - vim.ui.input = function(opts, on_confirm) - prompt_text = opts.prompt - on_confirm('n') - end - - local s = pending.store() - local t = s:add({ description = 'Old' }) - s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) - pending.archive('3w') - - assert.is_not_nil(prompt_text) - assert.truthy(prompt_text:find('21d')) - assert.truthy(prompt_text:find('1 task')) + store.unload() + store.load() + assert.are.equal(0, #store.active_tasks()) end) end) diff --git a/spec/complete_spec.lua b/spec/complete_spec.lua deleted file mode 100644 index 98547e8..0000000 --- a/spec/complete_spec.lua +++ /dev/null @@ -1,173 +0,0 @@ -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 01d8aac..fda2165 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -1,31 +1,35 @@ 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') - s = store.new(tmpdir .. '/tasks.json') - s:load() + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + config.reset() + store.unload() + store.load() end) after_each(function() vim.fn.delete(tmpdir, 'rf') + vim.g.pending = nil + config.reset() end) describe('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) @@ -44,7 +48,7 @@ describe('diff', function() it('handles new tasks without ids', function() local lines = { - '# Inbox', + '## Inbox', '- [ ] New task here', } local result = diff.parse_buffer(lines) @@ -56,7 +60,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) @@ -65,28 +69,9 @@ 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) @@ -99,192 +84,139 @@ 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, s) - s:load() - local tasks = s:active_tasks() + diff.apply(lines) + store.unload() + store.load() + local tasks = store.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() - s:add({ description = 'Keep me' }) - s:add({ description = 'Delete me' }) - s:save() + store.add({ description = 'Keep me' }) + store.add({ description = 'Delete me' }) + store.save() local lines = { - '# Inbox', + '## Inbox', '/1/- [ ] Keep me', } - diff.apply(lines, s) - s:load() - local active = s:active_tasks() + diff.apply(lines) + store.unload() + store.load() + local active = store.active_tasks() assert.are.equal(1, #active) assert.are.equal('Keep me', active[1].description) - local deleted = s:get(2) + local deleted = store.get(2) assert.are.equal('deleted', deleted.status) end) it('updates modified tasks', function() - s:add({ description = 'Original' }) - s:save() + store.add({ description = 'Original' }) + store.save() local lines = { - '# Inbox', + '## Inbox', '/1/- [ ] Renamed', } - diff.apply(lines, s) - s:load() - local task = s:get(1) + diff.apply(lines) + store.unload() + store.load() + local task = store.get(1) assert.are.equal('Renamed', task.description) end) it('updates modified when description is renamed', function() - local t = s:add({ description = 'Original', category = 'Inbox' }) + local t = store.add({ description = 'Original', category = 'Inbox' }) t.modified = '2020-01-01T00:00:00Z' - s:save() + store.save() local lines = { - '# Inbox', + '## Inbox', '/1/- [ ] Renamed', } - diff.apply(lines, s) - s:load() - local task = s:get(1) + diff.apply(lines) + store.unload() + store.load() + local task = store.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() - s:add({ description = 'Original' }) - s:save() + store.add({ description = 'Original' }) + store.save() local lines = { - '# Inbox', + '## Inbox', '/1/- [ ] Original', '/1/- [ ] Copy of original', } - diff.apply(lines, s) - s:load() - local tasks = s:active_tasks() + diff.apply(lines) + store.unload() + store.load() + local tasks = store.active_tasks() assert.are.equal(2, #tasks) end) it('moves tasks between categories', function() - s:add({ description = 'Moving task', category = 'Inbox' }) - s:save() + store.add({ description = 'Moving task', category = 'Inbox' }) + store.save() local lines = { - '# Work', + '## Work', '/1/- [ ] Moving task', } - diff.apply(lines, s) - s:load() - local task = s:get(1) + diff.apply(lines) + store.unload() + store.load() + local task = store.get(1) assert.are.equal('Work', task.category) end) it('does not update modified when task is unchanged', function() - s:add({ description = 'Stable task', category = 'Inbox' }) - s:save() + store.add({ description = 'Stable task', category = 'Inbox' }) + store.save() local lines = { - '# Inbox', + '## Inbox', '/1/- [ ] Stable task', } - 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) + 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) assert.are.equal(modified_after_first, task.modified) end) - it('preserves due when not present in buffer line', function() - s:add({ description = 'Pay bill', due = '2026-03-15' }) - s:save() + it('clears due when removed from buffer line', function() + store.add({ description = 'Pay bill', due = '2026-03-15' }) + store.save() local lines = { - '# Inbox', + '## Inbox', '/1/- [ ] Pay bill', } - 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) + diff.apply(lines) + store.unload() + store.load() + local task = store.get(1) + assert.is_nil(task.due) end) it('clears priority when [N] is removed from buffer line', function() - s:add({ description = 'Task name', priority = 1 }) - s:save() + store.add({ description = 'Task name', priority = 1 }) + store.save() local lines = { - '# Inbox', + '## Inbox', '/1/- [ ] Task name', } - diff.apply(lines, s) - s:load() - local task = s:get(1) + diff.apply(lines) + store.unload() + store.load() + local task = store.get(1) assert.are.equal(0, task.priority) end) end) diff --git a/spec/edit_spec.lua b/spec/edit_spec.lua deleted file mode 100644 index 08ef9e0..0000000 --- a/spec/edit_spec.lua +++ /dev/null @@ -1,329 +0,0 @@ -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 deleted file mode 100644 index 5e00b60..0000000 --- a/spec/filter_spec.lua +++ /dev/null @@ -1,292 +0,0 @@ -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 deleted file mode 100644 index 1e0f7ef..0000000 --- a/spec/gtasks_spec.lua +++ /dev/null @@ -1,368 +0,0 @@ -require('spec.helpers') - -local gtasks = require('pending.sync.gtasks') - -describe('gtasks field conversion', function() - describe('due date helpers', function() - it('converts date-only to RFC 3339', function() - assert.equals('2026-03-15T00:00:00.000Z', gtasks._due_to_rfc3339('2026-03-15')) - end) - - it('converts datetime to RFC 3339 (strips time)', function() - assert.equals('2026-03-15T00:00:00.000Z', gtasks._due_to_rfc3339('2026-03-15T14:30')) - end) - - it('strips RFC 3339 to date-only', function() - assert.equals('2026-03-15', gtasks._rfc3339_to_date('2026-03-15T00:00:00.000Z')) - end) - end) - - describe('build_notes', function() - it('returns nil when no priority or recur', function() - assert.is_nil(gtasks._build_notes({ priority = 0, recur = nil })) - end) - - it('encodes priority', function() - assert.equals('pri:1', gtasks._build_notes({ priority = 1, recur = nil })) - end) - - it('encodes recur', function() - assert.equals('rec:weekly', gtasks._build_notes({ priority = 0, recur = 'weekly' })) - end) - - it('encodes completion-mode recur with ! prefix', function() - assert.equals( - 'rec:!daily', - gtasks._build_notes({ priority = 0, recur = 'daily', recur_mode = 'completion' }) - ) - end) - - it('encodes both priority and recur', function() - assert.equals('pri:1 rec:weekly', gtasks._build_notes({ priority = 1, recur = 'weekly' })) - end) - end) - - describe('parse_notes', function() - it('returns zeros/nils for nil input', function() - local pri, rec, mode = gtasks._parse_notes(nil) - assert.equals(0, pri) - assert.is_nil(rec) - assert.is_nil(mode) - end) - - it('parses priority', function() - local pri = gtasks._parse_notes('pri:1') - assert.equals(1, pri) - end) - - it('parses recur', function() - local _, rec = gtasks._parse_notes('rec:weekly') - assert.equals('weekly', rec) - end) - - it('parses completion-mode recur', function() - local _, rec, mode = gtasks._parse_notes('rec:!daily') - assert.equals('daily', rec) - assert.equals('completion', mode) - end) - - it('parses both priority and recur', function() - local pri, rec = gtasks._parse_notes('pri:1 rec:monthly') - assert.equals(1, pri) - assert.equals('monthly', rec) - end) - - it('round-trips through build_notes', function() - local task = { priority = 1, recur = 'weekly', recur_mode = nil } - local notes = gtasks._build_notes(task) - local pri, rec = gtasks._parse_notes(notes) - assert.equals(1, pri) - assert.equals('weekly', rec) - end) - end) - - describe('task_to_gtask', function() - it('maps description to title', function() - local body = gtasks._task_to_gtask({ - description = 'Buy milk', - status = 'pending', - priority = 0, - }) - assert.equals('Buy milk', body.title) - end) - - it('maps pending status to needsAction', function() - local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 0 }) - assert.equals('needsAction', body.status) - end) - - it('maps done status to completed', function() - local body = gtasks._task_to_gtask({ description = 'x', status = 'done', priority = 0 }) - assert.equals('completed', body.status) - end) - - it('converts due date to RFC 3339', function() - local body = gtasks._task_to_gtask({ - description = 'x', - status = 'pending', - priority = 0, - due = '2026-03-15', - }) - assert.equals('2026-03-15T00:00:00.000Z', body.due) - end) - - it('omits due when nil', function() - local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 0 }) - assert.is_nil(body.due) - end) - - it('includes notes when priority is set', function() - local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 1 }) - assert.equals('pri:1', body.notes) - end) - - it('omits notes when no extra fields', function() - local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 0 }) - assert.is_nil(body.notes) - end) - end) - - describe('gtask_to_fields', function() - it('maps title to description', function() - local fields = gtasks._gtask_to_fields({ title = 'Buy milk', status = 'needsAction' }, 'Work') - assert.equals('Buy milk', fields.description) - end) - - it('maps category from list name', function() - local fields = gtasks._gtask_to_fields({ title = 'x', status = 'needsAction' }, 'Personal') - assert.equals('Personal', fields.category) - end) - - it('maps needsAction to pending', function() - local fields = gtasks._gtask_to_fields({ title = 'x', status = 'needsAction' }, 'Work') - assert.equals('pending', fields.status) - end) - - it('maps completed to done', function() - local fields = gtasks._gtask_to_fields({ title = 'x', status = 'completed' }, 'Work') - assert.equals('done', fields.status) - end) - - it('strips due date to YYYY-MM-DD', function() - local fields = gtasks._gtask_to_fields({ - title = 'x', - status = 'needsAction', - due = '2026-03-15T00:00:00.000Z', - }, 'Work') - assert.equals('2026-03-15', fields.due) - end) - - it('parses priority from notes', function() - local fields = gtasks._gtask_to_fields({ - title = 'x', - status = 'needsAction', - notes = 'pri:1', - }, 'Work') - assert.equals(1, fields.priority) - end) - - it('parses recur from notes', function() - local fields = gtasks._gtask_to_fields({ - title = 'x', - status = 'needsAction', - notes = 'rec:weekly', - }, 'Work') - assert.equals('weekly', fields.recur) - end) - end) -end) - -describe('gtasks push_pass _gtasks_synced_at', function() - local helpers = require('spec.helpers') - local store_mod = require('pending.store') - local oauth = require('pending.sync.oauth') - local s - local orig_curl - - before_each(function() - local dir = helpers.tmpdir() - s = store_mod.new(dir .. '/pending.json') - s:load() - orig_curl = oauth.curl_request - end) - - after_each(function() - oauth.curl_request = orig_curl - end) - - it('sets _gtasks_synced_at after push create', function() - local task = - s:add({ description = 'New task', status = 'pending', category = 'Work', priority = 0 }) - - oauth.curl_request = function(method, url, _headers, _body) - if method == 'POST' and url:find('/tasks$') then - return { id = 'gtask-new-1' }, nil - end - return {}, nil - end - - local now_ts = '2026-03-05T10:00:00Z' - local tasklists = { Work = 'list-1' } - local by_id = {} - gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id) - - assert.is_not_nil(task._extra) - assert.equals('2026-03-05T10:00:00Z', task._extra['_gtasks_synced_at']) - end) - - it('skips update when modified <= _gtasks_synced_at', function() - local task = - s:add({ description = 'Existing task', status = 'pending', category = 'Work', priority = 0 }) - task._extra = { - _gtasks_task_id = 'remote-1', - _gtasks_list_id = 'list-1', - _gtasks_synced_at = '2026-03-05T10:00:00Z', - } - task.modified = '2026-03-05T09:00:00Z' - - local patch_called = false - oauth.curl_request = function(method, _url, _headers, _body) - if method == 'PATCH' then - patch_called = true - end - return {}, nil - end - - local now_ts = '2026-03-05T11:00:00Z' - local tasklists = { Work = 'list-1' } - local by_id = { ['remote-1'] = task } - gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id) - - assert.is_false(patch_called) - end) - - it('pushes update when modified > _gtasks_synced_at', function() - local task = - s:add({ description = 'Changed task', status = 'pending', category = 'Work', priority = 0 }) - task._extra = { - _gtasks_task_id = 'remote-2', - _gtasks_list_id = 'list-1', - _gtasks_synced_at = '2026-03-05T08:00:00Z', - } - task.modified = '2026-03-05T09:00:00Z' - - local patch_called = false - oauth.curl_request = function(method, _url, _headers, _body) - if method == 'PATCH' then - patch_called = true - end - return {}, nil - end - - local now_ts = '2026-03-05T11:00:00Z' - local tasklists = { Work = 'list-1' } - local by_id = { ['remote-2'] = task } - gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id) - - assert.is_true(patch_called) - end) - - it('pushes update when no _gtasks_synced_at (backwards compat)', function() - local task = - s:add({ description = 'Old task', status = 'pending', category = 'Work', priority = 0 }) - task._extra = { - _gtasks_task_id = 'remote-3', - _gtasks_list_id = 'list-1', - } - task.modified = '2026-01-01T00:00:00Z' - - local patch_called = false - oauth.curl_request = function(method, _url, _headers, _body) - if method == 'PATCH' then - patch_called = true - end - return {}, nil - end - - local now_ts = '2026-03-05T11:00:00Z' - local tasklists = { Work = 'list-1' } - local by_id = { ['remote-3'] = task } - gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id) - - assert.is_true(patch_called) - end) -end) - -describe('gtasks detect_remote_deletions', function() - local helpers = require('spec.helpers') - local store_mod = require('pending.store') - local s - - before_each(function() - local dir = helpers.tmpdir() - s = store_mod.new(dir .. '/pending.json') - s:load() - end) - - it('clears remote IDs when list was fetched but task ID is absent', function() - local task = - s:add({ description = 'Gone remote', status = 'pending', category = 'Work', priority = 0 }) - task._extra = { - _gtasks_task_id = 'old-remote-id', - _gtasks_list_id = 'list-1', - _gtasks_synced_at = '2026-01-01T00:00:00Z', - } - - local seen = {} - local fetched = { ['list-1'] = true } - local now_ts = '2026-03-05T10:00:00Z' - - local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts) - - assert.equals(1, unlinked) - assert.is_nil(task._extra) - assert.equals('2026-03-05T10:00:00Z', task.modified) - end) - - it('leaves task untouched when its list fetch failed', function() - local task = s:add({ - description = 'Unknown list task', - status = 'pending', - category = 'Work', - priority = 0, - }) - task._extra = { - _gtasks_task_id = 'remote-id', - _gtasks_list_id = 'list-unfetched', - } - - local seen = {} - local fetched = {} - local now_ts = '2026-03-05T10:00:00Z' - - local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts) - - assert.equals(0, unlinked) - assert.is_not_nil(task._extra) - assert.equals('remote-id', task._extra['_gtasks_task_id']) - end) - - it('skips tasks with status == deleted', function() - local task = - s:add({ description = 'Deleted task', status = 'deleted', category = 'Work', priority = 0 }) - task._extra = { - _gtasks_task_id = 'remote-del', - _gtasks_list_id = 'list-1', - } - - local seen = {} - local fetched = { ['list-1'] = true } - local now_ts = '2026-03-05T10:00:00Z' - - local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts) - - assert.equals(0, unlinked) - assert.is_not_nil(task._extra) - assert.equals('remote-del', task._extra['_gtasks_task_id']) - end) -end) diff --git a/spec/icons_spec.lua b/spec/icons_spec.lua deleted file mode 100644 index 47b518c..0000000 --- a/spec/icons_spec.lua +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index a4a6f1d..0000000 --- a/spec/oauth_spec.lua +++ /dev/null @@ -1,235 +0,0 @@ -require('spec.helpers') - -local config = require('pending.config') -local oauth = require('pending.sync.oauth') - -describe('oauth', function() - local tmpdir - - before_each(function() - tmpdir = vim.fn.tempname() - vim.fn.mkdir(tmpdir, 'p') - vim.g.pending = { data_path = tmpdir .. '/tasks.json' } - config.reset() - end) - - after_each(function() - vim.fn.delete(tmpdir, 'rf') - vim.g.pending = nil - config.reset() - end) - - describe('url_encode', function() - it('leaves alphanumerics unchanged', function() - assert.equals('hello123', oauth.url_encode('hello123')) - end) - - it('encodes spaces', function() - assert.equals('hello%20world', oauth.url_encode('hello world')) - end) - - it('encodes special characters', function() - assert.equals('a%3Db%26c', oauth.url_encode('a=b&c')) - end) - - it('preserves hyphens, dots, underscores, tildes', function() - assert.equals('a-b.c_d~e', oauth.url_encode('a-b.c_d~e')) - end) - end) - - describe('load_json_file', function() - it('returns nil for missing file', function() - assert.is_nil(oauth.load_json_file(tmpdir .. '/nonexistent.json')) - end) - - it('returns nil for empty file', function() - local path = tmpdir .. '/empty.json' - local f = io.open(path, 'w') - f:write('') - f:close() - assert.is_nil(oauth.load_json_file(path)) - end) - - it('returns nil for invalid JSON', function() - local path = tmpdir .. '/bad.json' - local f = io.open(path, 'w') - f:write('not json') - f:close() - assert.is_nil(oauth.load_json_file(path)) - end) - - it('parses valid JSON', function() - local path = tmpdir .. '/good.json' - local f = io.open(path, 'w') - f:write('{"key":"value"}') - f:close() - local data = oauth.load_json_file(path) - assert.equals('value', data.key) - end) - end) - - describe('save_json_file', function() - it('creates parent directories', function() - local path = tmpdir .. '/sub/dir/file.json' - local ok = oauth.save_json_file(path, { test = true }) - assert.is_true(ok) - local data = oauth.load_json_file(path) - assert.is_true(data.test) - end) - - it('sets restrictive permissions', function() - local path = tmpdir .. '/secret.json' - oauth.save_json_file(path, { x = 1 }) - local perms = vim.fn.getfperm(path) - assert.equals('rw-------', perms) - end) - end) - - describe('resolve_credentials', function() - it('uses config fields when set', function() - config.reset() - vim.g.pending = { - data_path = tmpdir .. '/tasks.json', - sync = { - gtasks = { - client_id = 'config-id', - client_secret = 'config-secret', - }, - }, - } - local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) - local creds = c:resolve_credentials() - assert.equals('config-id', creds.client_id) - assert.equals('config-secret', creds.client_secret) - end) - - it('uses credentials file when config fields absent', function() - local cred_path = tmpdir .. '/creds.json' - oauth.save_json_file(cred_path, { - client_id = 'file-id', - client_secret = 'file-secret', - }) - config.reset() - vim.g.pending = { - data_path = tmpdir .. '/tasks.json', - sync = { gtasks = { credentials_path = cred_path } }, - } - local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) - local creds = c:resolve_credentials() - assert.equals('file-id', creds.client_id) - assert.equals('file-secret', creds.client_secret) - end) - - it('unwraps installed wrapper format', function() - local cred_path = tmpdir .. '/wrapped.json' - oauth.save_json_file(cred_path, { - installed = { - client_id = 'wrapped-id', - client_secret = 'wrapped-secret', - }, - }) - config.reset() - vim.g.pending = { - data_path = tmpdir .. '/tasks.json', - sync = { gcal = { credentials_path = cred_path } }, - } - local c = oauth.new({ name = 'gcal', scope = 'x', port = 0, config_key = 'gcal' }) - local creds = c:resolve_credentials() - assert.equals('wrapped-id', creds.client_id) - assert.equals('wrapped-secret', creds.client_secret) - end) - - it('falls back to bundled credentials', function() - config.reset() - vim.g.pending = { data_path = tmpdir .. '/tasks.json' } - local orig_load = oauth.load_json_file - oauth.load_json_file = function() - return nil - end - local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) - local creds = c:resolve_credentials() - oauth.load_json_file = orig_load - assert.equals(oauth._BUNDLED_CLIENT_ID, creds.client_id) - assert.equals(oauth._BUNDLED_CLIENT_SECRET, creds.client_secret) - end) - - it('prefers config fields over credentials file', function() - local cred_path = tmpdir .. '/creds2.json' - oauth.save_json_file(cred_path, { - client_id = 'file-id', - client_secret = 'file-secret', - }) - config.reset() - vim.g.pending = { - data_path = tmpdir .. '/tasks.json', - sync = { - gtasks = { - credentials_path = cred_path, - client_id = 'config-id', - client_secret = 'config-secret', - }, - }, - } - local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) - local creds = c:resolve_credentials() - assert.equals('config-id', creds.client_id) - assert.equals('config-secret', creds.client_secret) - end) - end) - - describe('token_path', function() - it('includes backend name', function() - local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) - assert.truthy(c:token_path():match('gtasks_tokens%.json$')) - end) - - it('differs between backends', function() - local g = oauth.new({ name = 'gcal', scope = 'x', port = 0, config_key = 'gcal' }) - local t = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) - assert.not_equals(g:token_path(), t:token_path()) - end) - end) - - describe('load_tokens / save_tokens', function() - it('round-trips tokens', function() - local c = oauth.new({ name = 'test', scope = 'x', port = 0, config_key = 'gtasks' }) - local path = c:token_path() - local dir = vim.fn.fnamemodify(path, ':h') - vim.fn.mkdir(dir, 'p') - local tokens = { - access_token = 'at', - refresh_token = 'rt', - expires_in = 3600, - obtained_at = 1000, - } - c:save_tokens(tokens) - local loaded = c:load_tokens() - assert.equals('at', loaded.access_token) - assert.equals('rt', loaded.refresh_token) - vim.fn.delete(dir, 'rf') - end) - end) - - describe('auth_headers', function() - it('includes bearer token', function() - local headers = oauth.auth_headers('mytoken') - assert.equals('Authorization: Bearer mytoken', headers[1]) - assert.equals('Content-Type: application/json', headers[2]) - end) - end) - - describe('new', function() - it('creates client with correct fields', function() - local c = oauth.new({ - name = 'test', - scope = 'https://example.com', - port = 12345, - config_key = 'test', - }) - assert.equals('test', c.name) - assert.equals('https://example.com', c.scope) - assert.equals(12345, c.port) - assert.equals('test', c.config_key) - end) - end) -end) diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index 0820356..ca8047c 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -154,240 +154,6 @@ 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() @@ -415,115 +181,4 @@ describe('parse', function() assert.are.equal('2026-03-15', meta.due) end) end) - - describe('parse_duration_to_days', function() - it('parses days suffix', function() - assert.are.equal(7, parse.parse_duration_to_days('7d')) - end) - - it('parses weeks suffix', function() - assert.are.equal(21, parse.parse_duration_to_days('3w')) - end) - - it('parses months suffix (approximated as 30 days)', function() - assert.are.equal(60, parse.parse_duration_to_days('2m')) - end) - - it('parses bare integer as days', function() - assert.are.equal(30, parse.parse_duration_to_days('30')) - end) - - it('returns nil for nil input', function() - assert.is_nil(parse.parse_duration_to_days(nil)) - end) - - it('returns nil for empty string', function() - assert.is_nil(parse.parse_duration_to_days('')) - end) - - it('returns nil for unrecognized input', function() - assert.is_nil(parse.parse_duration_to_days('xyz')) - end) - - it('returns nil for negative numbers', function() - assert.is_nil(parse.parse_duration_to_days('-7d')) - end) - - it('handles single digit', function() - assert.are.equal(1, parse.parse_duration_to_days('1d')) - end) - - it('handles large numbers', function() - assert.are.equal(365, parse.parse_duration_to_days('365d')) - end) - end) - - describe('input_date_formats', function() - before_each(function() - config.reset() - end) - - after_each(function() - vim.g.pending = nil - config.reset() - end) - - it('parses MM/DD/YYYY format', function() - vim.g.pending = { input_date_formats = { '%m/%d/%Y' } } - config.reset() - local result = parse.resolve_date('03/15/2026') - assert.are.equal('2026-03-15', result) - end) - - it('parses DD-Mon-YYYY format', function() - vim.g.pending = { input_date_formats = { '%d-%b-%Y' } } - config.reset() - local result = parse.resolve_date('15-Mar-2026') - assert.are.equal('2026-03-15', result) - end) - - it('parses month name case-insensitively', function() - vim.g.pending = { input_date_formats = { '%d-%b-%Y' } } - config.reset() - local result = parse.resolve_date('15-MARCH-2026') - assert.are.equal('2026-03-15', result) - end) - - it('parses two-digit year', function() - vim.g.pending = { input_date_formats = { '%m/%d/%y' } } - config.reset() - local result = parse.resolve_date('03/15/26') - assert.are.equal('2026-03-15', result) - end) - - it('infers year when format has no year field', function() - vim.g.pending = { input_date_formats = { '%m/%d' } } - config.reset() - local result = parse.resolve_date('12/31') - assert.is_not_nil(result) - assert.truthy(result:match('^%d%d%d%d%-12%-31$')) - end) - - it('returns nil for non-matching input', function() - vim.g.pending = { input_date_formats = { '%m/%d/%Y' } } - config.reset() - local result = parse.resolve_date('not-a-date') - assert.is_nil(result) - end) - - it('tries formats in order, returns first match', function() - vim.g.pending = { input_date_formats = { '%d/%m/%Y', '%m/%d/%Y' } } - config.reset() - local result = parse.resolve_date('01/03/2026') - assert.are.equal('2026-03-01', result) - end) - - it('works with body() for inline due token', function() - vim.g.pending = { input_date_formats = { '%m/%d/%Y' } } - config.reset() - local desc, meta = parse.body('Pay rent due:03/15/2026') - assert.are.equal('Pay rent', desc) - assert.are.equal('2026-03-15', meta.due) - end) - end) end) diff --git a/spec/recur_spec.lua b/spec/recur_spec.lua deleted file mode 100644 index 53b7478..0000000 --- a/spec/recur_spec.lua +++ /dev/null @@ -1,223 +0,0 @@ -require('spec.helpers') - -describe('recur', function() - local recur = require('pending.recur') - - describe('parse', function() - it('parses daily', function() - local r = recur.parse('daily') - assert.are.equal('daily', r.freq) - assert.are.equal(1, r.interval) - assert.is_false(r.from_completion) - end) - - it('parses weekdays', function() - local r = recur.parse('weekdays') - assert.are.equal('weekly', r.freq) - assert.are.same({ 'MO', 'TU', 'WE', 'TH', 'FR' }, r.byday) - end) - - it('parses weekly', function() - local r = recur.parse('weekly') - assert.are.equal('weekly', r.freq) - assert.are.equal(1, r.interval) - end) - - it('parses biweekly', function() - local r = recur.parse('biweekly') - assert.are.equal('weekly', r.freq) - assert.are.equal(2, r.interval) - end) - - it('parses monthly', function() - local r = recur.parse('monthly') - assert.are.equal('monthly', r.freq) - assert.are.equal(1, r.interval) - end) - - it('parses quarterly', function() - local r = recur.parse('quarterly') - assert.are.equal('monthly', r.freq) - assert.are.equal(3, r.interval) - end) - - it('parses yearly', function() - local r = recur.parse('yearly') - assert.are.equal('yearly', r.freq) - assert.are.equal(1, r.interval) - end) - - it('parses annual as yearly', function() - local r = recur.parse('annual') - assert.are.equal('yearly', r.freq) - end) - - it('parses 3d as every 3 days', function() - local r = recur.parse('3d') - assert.are.equal('daily', r.freq) - assert.are.equal(3, r.interval) - end) - - it('parses 2w as biweekly', function() - local r = recur.parse('2w') - assert.are.equal('weekly', r.freq) - assert.are.equal(2, r.interval) - end) - - it('parses 6m as every 6 months', function() - local r = recur.parse('6m') - assert.are.equal('monthly', r.freq) - assert.are.equal(6, r.interval) - end) - - it('parses 2y as every 2 years', function() - local r = recur.parse('2y') - assert.are.equal('yearly', r.freq) - assert.are.equal(2, r.interval) - end) - - it('parses ! prefix as completion-based', function() - local r = recur.parse('!weekly') - assert.are.equal('weekly', r.freq) - assert.is_true(r.from_completion) - end) - - it('parses raw RRULE fragment', function() - local r = recur.parse('FREQ=MONTHLY;BYDAY=1MO') - assert.is_not_nil(r) - end) - - it('returns nil for invalid input', function() - assert.is_nil(recur.parse('')) - assert.is_nil(recur.parse('garbage')) - assert.is_nil(recur.parse('0d')) - end) - - it('is case insensitive', function() - local r = recur.parse('Weekly') - assert.are.equal('weekly', r.freq) - end) - end) - - describe('validate', function() - it('returns true for valid specs', function() - assert.is_true(recur.validate('daily')) - assert.is_true(recur.validate('2w')) - assert.is_true(recur.validate('!monthly')) - end) - - it('returns false for invalid specs', function() - assert.is_false(recur.validate('garbage')) - assert.is_false(recur.validate('')) - end) - end) - - describe('next_due', function() - it('advances daily by 1 day', function() - local result = recur.next_due('2099-03-01', 'daily', 'scheduled') - assert.are.equal('2099-03-02', result) - end) - - it('advances weekly by 7 days', function() - local result = recur.next_due('2099-03-01', 'weekly', 'scheduled') - assert.are.equal('2099-03-08', result) - end) - - it('advances monthly and clamps day', function() - local result = recur.next_due('2099-01-31', 'monthly', 'scheduled') - assert.are.equal('2099-02-28', result) - end) - - it('advances yearly and handles leap year', function() - local result = recur.next_due('2096-02-29', 'yearly', 'scheduled') - assert.are.equal('2097-02-28', result) - end) - - it('advances biweekly by 14 days', function() - local result = recur.next_due('2099-03-01', 'biweekly', 'scheduled') - assert.are.equal('2099-03-15', result) - end) - - it('advances quarterly by 3 months', function() - local result = recur.next_due('2099-01-15', 'quarterly', 'scheduled') - assert.are.equal('2099-04-15', result) - end) - - it('scheduled mode skips to future if overdue', function() - local result = recur.next_due('2020-01-01', 'yearly', 'scheduled') - local today = os.date('%Y-%m-%d') --[[@as string]] - assert.is_true(result > today) - end) - - it('completion mode advances from today', function() - local today = os.date('*t') --[[@as osdate]] - local expected = os.date( - '%Y-%m-%d', - os.time({ - year = today.year, - month = today.month, - day = today.day + 7, - }) - ) - local result = recur.next_due('2020-01-01', 'weekly', 'completion') - assert.are.equal(expected, result) - end) - - it('advances 3d by 3 days', function() - local result = recur.next_due('2099-06-10', '3d', 'scheduled') - assert.are.equal('2099-06-13', result) - end) - end) - - describe('to_rrule', function() - it('converts daily', function() - assert.are.equal('RRULE:FREQ=DAILY', recur.to_rrule('daily')) - end) - - it('converts weekly', function() - assert.are.equal('RRULE:FREQ=WEEKLY', recur.to_rrule('weekly')) - end) - - it('converts biweekly with interval', function() - assert.are.equal('RRULE:FREQ=WEEKLY;INTERVAL=2', recur.to_rrule('biweekly')) - end) - - it('converts weekdays with BYDAY', function() - assert.are.equal('RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', recur.to_rrule('weekdays')) - end) - - it('converts monthly', function() - assert.are.equal('RRULE:FREQ=MONTHLY', recur.to_rrule('monthly')) - end) - - it('converts quarterly with interval', function() - assert.are.equal('RRULE:FREQ=MONTHLY;INTERVAL=3', recur.to_rrule('quarterly')) - end) - - it('converts yearly', function() - assert.are.equal('RRULE:FREQ=YEARLY', recur.to_rrule('yearly')) - end) - - it('converts 2w with interval', function() - assert.are.equal('RRULE:FREQ=WEEKLY;INTERVAL=2', recur.to_rrule('2w')) - end) - - it('prefixes raw RRULE fragment', function() - assert.are.equal('RRULE:FREQ=MONTHLY;BYDAY=1MO', recur.to_rrule('FREQ=MONTHLY;BYDAY=1MO')) - end) - - it('returns empty string for invalid spec', function() - assert.are.equal('', recur.to_rrule('garbage')) - end) - end) - - describe('shorthand_list', function() - it('returns a list of named shorthands', function() - local list = recur.shorthand_list() - assert.is_true(#list >= 8) - assert.is_true(vim.tbl_contains(list, 'daily')) - assert.is_true(vim.tbl_contains(list, 'weekly')) - assert.is_true(vim.tbl_contains(list, 'monthly')) - end) - end) -end) diff --git a/spec/s3_spec.lua b/spec/s3_spec.lua deleted file mode 100644 index a9b1dbd..0000000 --- a/spec/s3_spec.lua +++ /dev/null @@ -1,558 +0,0 @@ -require('spec.helpers') - -local config = require('pending.config') -local util = require('pending.sync.util') - -describe('s3', function() - local tmpdir - local pending - local s3 - local orig_system - - before_each(function() - tmpdir = vim.fn.tempname() - vim.fn.mkdir(tmpdir, 'p') - vim.g.pending = { - data_path = tmpdir .. '/tasks.json', - sync = { s3 = { bucket = 'test-bucket', key = 'test.json' } }, - } - config.reset() - package.loaded['pending'] = nil - package.loaded['pending.sync.s3'] = nil - pending = require('pending') - s3 = require('pending.sync.s3') - orig_system = util.system - end) - - after_each(function() - util.system = orig_system - vim.fn.delete(tmpdir, 'rf') - vim.g.pending = nil - config.reset() - package.loaded['pending'] = nil - package.loaded['pending.sync.s3'] = nil - end) - - it('has correct name', function() - assert.equals('s3', s3.name) - end) - - it('has auth function', function() - assert.equals('function', type(s3.auth)) - end) - - it('has auth_complete returning profile', function() - local completions = s3.auth_complete() - assert.is_true(vim.tbl_contains(completions, 'profile')) - end) - - it('has push, pull, sync functions', function() - assert.equals('function', type(s3.push)) - assert.equals('function', type(s3.pull)) - assert.equals('function', type(s3.sync)) - end) - - it('has health function', function() - assert.equals('function', type(s3.health)) - end) - - describe('ensure_sync_id', function() - it('assigns a UUID-like sync id', function() - local task = { _extra = nil, modified = '2026-01-01T00:00:00Z' } - local id = s3._ensure_sync_id(task) - assert.is_not_nil(id) - assert.truthy( - id:match('^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$') - ) - assert.equals(id, task._extra['_s3_sync_id']) - end) - - it('returns existing sync id without regenerating', function() - local task = { - _extra = { _s3_sync_id = 'existing-id' }, - modified = '2026-01-01T00:00:00Z', - } - local id = s3._ensure_sync_id(task) - assert.equals('existing-id', id) - end) - end) - - describe('auth', function() - it('reports success on valid credentials', function() - util.system = function(args) - if vim.tbl_contains(args, 'get-caller-identity') then - return { - code = 0, - stdout = '{"Account":"123456","Arn":"arn:aws:iam::user/test"}', - stderr = '', - } - end - return { code = 0, stdout = '', stderr = '' } - end - local msg - local orig_notify = vim.notify - vim.notify = function(m) - msg = m - end - s3.auth() - vim.notify = orig_notify - assert.truthy(msg and msg:find('authenticated')) - end) - - it('skips bucket creation when bucket is configured', function() - util.system = function(args) - if vim.tbl_contains(args, 'get-caller-identity') then - return { - code = 0, - stdout = '{"Account":"123456","Arn":"arn:aws:iam::user/test"}', - stderr = '', - } - end - return { code = 0, stdout = '', stderr = '' } - end - local orig_input = util.input - local input_called = false - util.input = function() - input_called = true - return nil - end - s3.auth() - util.input = orig_input - assert.is_false(input_called) - end) - - it('detects SSO expiry', function() - util.system = function(args) - if vim.tbl_contains(args, 'get-caller-identity') then - return { code = 1, stdout = '', stderr = 'Error: SSO session expired' } - end - return { code = 0, stdout = '', stderr = '' } - end - local msg - local orig_notify = vim.notify - vim.notify = function(m) - msg = m - end - s3.auth() - vim.notify = orig_notify - assert.truthy(msg and msg:find('SSO')) - end) - - it('detects missing credentials', function() - util.system = function() - return { code = 1, stdout = '', stderr = 'Unable to locate credentials' } - end - local msg - local orig_notify = vim.notify - vim.notify = function(m, level) - if level == vim.log.levels.ERROR then - msg = m - end - end - s3.auth() - vim.notify = orig_notify - assert.truthy(msg and msg:find('no AWS credentials')) - end) - end) - - describe('auth bucket creation', function() - local orig_input - - before_each(function() - vim.g.pending = { data_path = tmpdir .. '/tasks.json', sync = { s3 = {} } } - config.reset() - package.loaded['pending'] = nil - package.loaded['pending.sync.s3'] = nil - pending = require('pending') - s3 = require('pending.sync.s3') - orig_input = util.input - end) - - after_each(function() - util.input = orig_input - end) - - it('prompts for bucket when none configured', function() - local input_calls = {} - util.input = function(opts) - table.insert(input_calls, opts) - if opts.prompt:find('bucket') then - return 'my-bucket' - end - return '' - end - local create_args - util.system = function(args) - if vim.tbl_contains(args, 'get-caller-identity') then - return { - code = 0, - stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}', - stderr = '', - } - end - if vim.tbl_contains(args, 'configure') then - return { code = 0, stdout = 'us-west-2\n', stderr = '' } - end - if vim.tbl_contains(args, 'create-bucket') then - create_args = args - return { code = 0, stdout = '', stderr = '' } - end - return { code = 0, stdout = '', stderr = '' } - end - local msg - local orig_notify = vim.notify - vim.notify = function(m) - msg = m - end - s3.auth() - vim.notify = orig_notify - assert.equals(2, #input_calls) - assert.is_not_nil(create_args) - assert.truthy(vim.tbl_contains(create_args, 'my-bucket')) - assert.truthy(msg and msg:find('bucket created')) - end) - - it('cancels when user provides nil bucket name', function() - util.input = function(opts) - if opts.prompt:find('bucket') then - return nil - end - return '' - end - util.system = function(args) - if vim.tbl_contains(args, 'get-caller-identity') then - return { - code = 0, - stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}', - stderr = '', - } - end - return { code = 0, stdout = '', stderr = '' } - end - local msg - local orig_notify = vim.notify - vim.notify = function(m) - msg = m - end - s3.auth() - vim.notify = orig_notify - assert.truthy(msg and msg:find('cancelled')) - end) - - it('omits LocationConstraint for us-east-1', function() - util.input = function(opts) - if opts.prompt:find('bucket') then - return 'my-bucket' - end - if opts.prompt:find('region') then - return 'us-east-1' - end - return '' - end - local create_args - util.system = function(args) - if vim.tbl_contains(args, 'get-caller-identity') then - return { - code = 0, - stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}', - stderr = '', - } - end - if vim.tbl_contains(args, 'configure') then - return { code = 0, stdout = 'us-east-1\n', stderr = '' } - end - if vim.tbl_contains(args, 'create-bucket') then - create_args = args - return { code = 0, stdout = '', stderr = '' } - end - return { code = 0, stdout = '', stderr = '' } - end - s3.auth() - assert.is_not_nil(create_args) - local joined = table.concat(create_args, ' ') - assert.falsy(joined:find('LocationConstraint')) - end) - - it('includes LocationConstraint for non-us-east-1 regions', function() - util.input = function(opts) - if opts.prompt:find('bucket') then - return 'my-bucket' - end - if opts.prompt:find('region') then - return 'eu-west-1' - end - return '' - end - local create_args - util.system = function(args) - if vim.tbl_contains(args, 'get-caller-identity') then - return { - code = 0, - stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}', - stderr = '', - } - end - if vim.tbl_contains(args, 'configure') then - return { code = 0, stdout = 'eu-west-1\n', stderr = '' } - end - if vim.tbl_contains(args, 'create-bucket') then - create_args = args - return { code = 0, stdout = '', stderr = '' } - end - return { code = 0, stdout = '', stderr = '' } - end - s3.auth() - assert.is_not_nil(create_args) - assert.truthy(vim.tbl_contains(create_args, 'LocationConstraint=eu-west-1')) - end) - - it('reports error on bucket creation failure', function() - util.input = function(opts) - if opts.prompt:find('bucket') then - return 'bad-bucket' - end - return '' - end - util.system = function(args) - if vim.tbl_contains(args, 'get-caller-identity') then - return { - code = 0, - stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}', - stderr = '', - } - end - if vim.tbl_contains(args, 'configure') then - return { code = 0, stdout = 'us-east-1\n', stderr = '' } - end - if vim.tbl_contains(args, 'create-bucket') then - return { code = 1, stdout = '', stderr = 'BucketAlreadyExists' } - end - return { code = 0, stdout = '', stderr = '' } - end - local msg - local orig_notify = vim.notify - vim.notify = function(m, level) - if level == vim.log.levels.ERROR then - msg = m - end - end - s3.auth() - vim.notify = orig_notify - assert.truthy(msg and msg:find('bucket creation failed')) - end) - - it('defaults region to us-east-1 when aws configure returns nothing', function() - util.input = function(opts) - if opts.prompt:find('bucket') then - return 'my-bucket' - end - return '' - end - local create_args - util.system = function(args) - if vim.tbl_contains(args, 'get-caller-identity') then - return { - code = 0, - stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}', - stderr = '', - } - end - if vim.tbl_contains(args, 'configure') then - return { code = 1, stdout = '', stderr = '' } - end - if vim.tbl_contains(args, 'create-bucket') then - create_args = args - return { code = 0, stdout = '', stderr = '' } - end - return { code = 0, stdout = '', stderr = '' } - end - s3.auth() - assert.is_not_nil(create_args) - assert.truthy(vim.tbl_contains(create_args, 'us-east-1')) - local joined = table.concat(create_args, ' ') - assert.falsy(joined:find('LocationConstraint')) - end) - end) - - describe('push', function() - it('uploads store to S3', function() - local s = pending.store() - s:load() - s:add({ description = 'Test task', status = 'pending', category = 'Work', priority = 0 }) - s:save() - - local captured_args - util.system = function(args) - if vim.tbl_contains(args, 's3') then - captured_args = args - return { code = 0, stdout = '', stderr = '' } - end - return { code = 0, stdout = '', stderr = '' } - end - - s3.push() - - assert.is_not_nil(captured_args) - local joined = table.concat(captured_args, ' ') - assert.truthy(joined:find('s3://test%-bucket/test%.json')) - end) - - it('errors when bucket is not configured', function() - vim.g.pending = { data_path = tmpdir .. '/tasks.json', sync = { s3 = {} } } - config.reset() - package.loaded['pending'] = nil - package.loaded['pending.sync.s3'] = nil - pending = require('pending') - s3 = require('pending.sync.s3') - - local msg - local orig_notify = vim.notify - vim.notify = function(m, level) - if level == vim.log.levels.ERROR then - msg = m - end - end - s3.push() - vim.notify = orig_notify - assert.truthy(msg and msg:find('bucket is required')) - end) - end) - - describe('pull merge', function() - it('merges remote tasks by sync_id', function() - local store_mod = require('pending.store') - local s = pending.store() - s:load() - local local_task = s:add({ - description = 'Local task', - status = 'pending', - category = 'Work', - priority = 0, - }) - local_task._extra = { _s3_sync_id = 'sync-1' } - local_task.modified = '2026-03-01T00:00:00Z' - s:save() - - local remote_path = tmpdir .. '/remote.json' - local remote_store = store_mod.new(remote_path) - remote_store:load() - local remote_task = remote_store:add({ - description = 'Updated remotely', - status = 'pending', - category = 'Work', - priority = 1, - }) - remote_task._extra = { _s3_sync_id = 'sync-1' } - remote_task.modified = '2026-03-05T00:00:00Z' - - local new_remote = remote_store:add({ - description = 'New remote task', - status = 'pending', - category = 'Personal', - priority = 0, - }) - new_remote._extra = { _s3_sync_id = 'sync-2' } - new_remote.modified = '2026-03-04T00:00:00Z' - remote_store:save() - - util.system = function(args) - if vim.tbl_contains(args, 's3') and vim.tbl_contains(args, 'cp') then - for i, arg in ipairs(args) do - if arg:match('^s3://') then - local dest = args[i + 1] - if dest and not dest:match('^s3://') then - local src = io.open(remote_path, 'r') - local content = src:read('*a') - src:close() - local f = io.open(dest, 'w') - f:write(content) - f:close() - end - break - end - end - return { code = 0, stdout = '', stderr = '' } - end - return { code = 0, stdout = '', stderr = '' } - end - - s3.pull() - - s:load() - local tasks = s:tasks() - assert.equals(2, #tasks) - - local found_updated = false - local found_new = false - for _, t in ipairs(tasks) do - if t._extra and t._extra['_s3_sync_id'] == 'sync-1' then - assert.equals('Updated remotely', t.description) - assert.equals(1, t.priority) - found_updated = true - end - if t._extra and t._extra['_s3_sync_id'] == 'sync-2' then - assert.equals('New remote task', t.description) - found_new = true - end - end - assert.is_true(found_updated) - assert.is_true(found_new) - end) - - it('keeps local version when local is newer', function() - local s = pending.store() - s:load() - local local_task = s:add({ - description = 'Local version', - status = 'pending', - category = 'Work', - priority = 0, - }) - local_task._extra = { _s3_sync_id = 'sync-3' } - local_task.modified = '2026-03-10T00:00:00Z' - s:save() - - local store_mod = require('pending.store') - local remote_path = tmpdir .. '/remote2.json' - local remote_store = store_mod.new(remote_path) - remote_store:load() - local remote_task = remote_store:add({ - description = 'Older remote', - status = 'pending', - category = 'Work', - priority = 0, - }) - remote_task._extra = { _s3_sync_id = 'sync-3' } - remote_task.modified = '2026-03-05T00:00:00Z' - remote_store:save() - - util.system = function(args) - if vim.tbl_contains(args, 's3') and vim.tbl_contains(args, 'cp') then - for i, arg in ipairs(args) do - if arg:match('^s3://') then - local dest = args[i + 1] - if dest and not dest:match('^s3://') then - local src = io.open(remote_path, 'r') - local content = src:read('*a') - src:close() - local f = io.open(dest, 'w') - f:write(content) - f:close() - end - break - end - end - return { code = 0, stdout = '', stderr = '' } - end - return { code = 0, stdout = '', stderr = '' } - end - - s3.pull() - - s:load() - local tasks = s:tasks() - assert.equals(1, #tasks) - assert.equals('Local version', tasks[1].description) - end) - end) -end) diff --git a/spec/status_spec.lua b/spec/status_spec.lua deleted file mode 100644 index e2d4223..0000000 --- a/spec/status_spec.lua +++ /dev/null @@ -1,260 +0,0 @@ -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 827dd21..bb6266d 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -5,30 +5,31 @@ local store = require('pending.store') describe('store', function() local tmpdir - local s before_each(function() tmpdir = vim.fn.tempname() vim.fn.mkdir(tmpdir, 'p') - s = store.new(tmpdir .. '/tasks.json') - s:load() + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + config.reset() + store.unload() 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 = s:load() + local data = store.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 = tmpdir .. '/tasks.json' + local path = config.get().data_path local f = io.open(path, 'w') f:write(vim.json.encode({ version = 1, @@ -51,7 +52,7 @@ describe('store', function() }, })) f:close() - local data = s:load() + local data = store.load() assert.are.equal(3, data.next_id) assert.are.equal(2, #data.tasks) assert.are.equal('Pending one', data.tasks[1].description) @@ -59,7 +60,7 @@ describe('store', function() end) it('preserves unknown fields', function() - local path = tmpdir .. '/tasks.json' + local path = config.get().data_path local f = io.open(path, 'w') f:write(vim.json.encode({ version = 1, @@ -76,8 +77,8 @@ describe('store', function() }, })) f:close() - s:load() - local task = s:get(1) + store.load() + local task = store.get(1) assert.is_not_nil(task._extra) assert.are.equal('hello', task._extra.custom_field) end) @@ -85,8 +86,9 @@ describe('store', function() describe('add', function() it('creates a task with incremented id', function() - local t1 = s:add({ description = 'First' }) - local t2 = s:add({ description = 'Second' }) + store.load() + local t1 = store.add({ description = 'First' }) + local t2 = store.add({ description = 'Second' }) assert.are.equal(1, t1.id) assert.are.equal(2, t2.id) assert.are.equal('pending', t1.status) @@ -94,54 +96,60 @@ describe('store', function() end) it('uses provided category', function() - local t = s:add({ description = 'Test', category = 'Work' }) + store.load() + local t = store.add({ description = 'Test', category = 'Work' }) assert.are.equal('Work', t.category) end) end) describe('update', function() it('updates fields and sets modified', function() - local t = s:add({ description = 'Original' }) + store.load() + local t = store.add({ description = 'Original' }) t.modified = '2025-01-01T00:00:00Z' - s:update(t.id, { description = 'Updated' }) - local updated = s:get(t.id) + store.update(t.id, { description = 'Updated' }) + local updated = store.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() - local t = s:add({ description = 'Test' }) + store.load() + local t = store.add({ description = 'Test' }) assert.is_nil(t['end']) - s:update(t.id, { status = 'done' }) - local updated = s:get(t.id) + store.update(t.id, { status = 'done' }) + local updated = store.get(t.id) assert.is_not_nil(updated['end']) end) it('does not overwrite id or entry', function() - local t = s:add({ description = 'Immutable fields' }) + store.load() + local t = store.add({ description = 'Immutable fields' }) local original_id = t.id local original_entry = t.entry - s:update(t.id, { id = 999, entry = 'x' }) - local updated = s:get(original_id) + store.update(t.id, { id = 999, entry = 'x' }) + local updated = store.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() - 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) + 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) assert.are.equal(first_end, task['end']) end) end) describe('delete', function() it('marks task as deleted', function() - local t = s:add({ description = 'To delete' }) - s:delete(t.id) - local deleted = s:get(t.id) + store.load() + local t = store.add({ description = 'To delete' }) + store.delete(t.id) + local deleted = store.get(t.id) assert.are.equal('deleted', deleted.status) assert.is_not_nil(deleted['end']) end) @@ -149,10 +157,12 @@ describe('store', function() describe('save and round-trip', function() it('persists and reloads correctly', function() - s:add({ description = 'Persisted', category = 'Work', priority = 1 }) - s:save() - s:load() - local tasks = s:active_tasks() + store.load() + store.add({ description = 'Persisted', category = 'Work', priority = 1 }) + store.save() + store.unload() + store.load() + local tasks = store.active_tasks() assert.are.equal(1, #tasks) assert.are.equal('Persisted', tasks[1].description) assert.are.equal('Work', tasks[1].category) @@ -160,7 +170,7 @@ describe('store', function() end) it('round-trips unknown fields', function() - local path = tmpdir .. '/tasks.json' + local path = config.get().data_path local f = io.open(path, 'w') f:write(vim.json.encode({ version = 1, @@ -177,78 +187,22 @@ describe('store', function() }, })) f:close() - s:load() - s:save() - s:load() - local task = s:get(1) + store.load() + store.save() + store.unload() + store.load() + local task = store.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('folded_categories', function() - it('defaults to empty table when missing from JSON', function() - local path = tmpdir .. '/tasks.json' - local f = io.open(path, 'w') - f:write(vim.json.encode({ - version = 1, - next_id = 1, - tasks = {}, - })) - f:close() - s:load() - assert.are.same({}, s:get_folded_categories()) - end) - - it('round-trips folded categories through save and load', function() - s:set_folded_categories({ 'Work', 'Home' }) - s:save() - s:load() - assert.are.same({ 'Work', 'Home' }, s:get_folded_categories()) - end) - - it('persists empty list', function() - s:set_folded_categories({}) - s:save() - s:load() - assert.are.same({}, s:get_folded_categories()) - end) - end) - describe('active_tasks', function() it('excludes deleted tasks', function() - s:add({ description = 'Active' }) - local t2 = s:add({ description = 'To delete' }) - s:delete(t2.id) - local active = s:active_tasks() + store.load() + store.add({ description = 'Active' }) + local t2 = store.add({ description = 'To delete' }) + store.delete(t2.id) + local active = store.active_tasks() assert.are.equal(1, #active) assert.are.equal('Active', active[1].description) end) @@ -256,24 +210,27 @@ describe('store', function() describe('snapshot', function() it('returns a table of tasks', function() - s:add({ description = 'Snap one' }) - s:add({ description = 'Snap two' }) - local snap = s:snapshot() + store.load() + store.add({ description = 'Snap one' }) + store.add({ description = 'Snap two' }) + local snap = store.snapshot() assert.are.equal(2, #snap) end) it('returns a copy that does not affect the store', function() - local t = s:add({ description = 'Original' }) - local snap = s:snapshot() + store.load() + local t = store.add({ description = 'Original' }) + local snap = store.snapshot() snap[1].description = 'Mutated' - local live = s:get(t.id) + local live = store.get(t.id) assert.are.equal('Original', live.description) end) it('excludes deleted tasks', function() - local t = s:add({ description = 'Will be deleted' }) - s:delete(t.id) - local snap = s:snapshot() + store.load() + local t = store.add({ description = 'Will be deleted' }) + store.delete(t.id) + local snap = store.snapshot() assert.are.equal(0, #snap) end) end) diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua deleted file mode 100644 index 51156bf..0000000 --- a/spec/sync_spec.lua +++ /dev/null @@ -1,185 +0,0 @@ -require('spec.helpers') - -local config = require('pending.config') - -describe('sync', function() - local tmpdir - local pending - - before_each(function() - tmpdir = vim.fn.tempname() - vim.fn.mkdir(tmpdir, 'p') - vim.g.pending = { data_path = tmpdir .. '/tasks.json' } - config.reset() - package.loaded['pending'] = nil - pending = require('pending') - end) - - after_each(function() - vim.fn.delete(tmpdir, 'rf') - vim.g.pending = nil - config.reset() - package.loaded['pending'] = nil - end) - - describe('dispatch', function() - it('errors on unknown subcommand', function() - local msg - local orig = vim.notify - vim.notify = function(m, level) - if level == vim.log.levels.ERROR then - msg = m - end - end - pending.command('notreal') - vim.notify = orig - assert.are.equal('[pending.nvim]: Unknown subcommand: notreal', msg) - end) - - it('errors on unknown action for valid backend', function() - local msg - local orig = vim.notify - vim.notify = function(m, level) - if level == vim.log.levels.ERROR then - msg = m - end - end - pending.command('gcal notreal') - vim.notify = orig - assert.are.equal("[pending.nvim]: gcal: No 'notreal' action.", msg) - end) - - it('lists actions when action is omitted', function() - local msg = nil - local orig = vim.notify - vim.notify = function(m) - msg = m - end - pending.command('gcal') - vim.notify = orig - assert.is_not_nil(msg) - assert.is_truthy(msg:find('push')) - end) - - it('routes explicit push action', function() - local called = false - local gcal = require('pending.sync.gcal') - local orig_push = gcal.push - gcal.push = function() - called = true - end - pending.command('gcal push') - gcal.push = orig_push - assert.is_true(called) - end) - - it('routes auth command', function() - local called = false - local orig_auth = pending.auth - pending.auth = function() - called = true - end - pending.command('auth') - pending.auth = orig_auth - assert.is_true(called) - end) - end) - - it('works with sync.gcal config', function() - config.reset() - vim.g.pending = { - data_path = tmpdir .. '/tasks.json', - sync = { gcal = { client_id = 'test-id' } }, - } - local cfg = config.get() - assert.are.equal('test-id', cfg.sync.gcal.client_id) - end) - - describe('gcal module', function() - it('has name field', function() - local gcal = require('pending.sync.gcal') - assert.are.equal('gcal', gcal.name) - end) - - it('has push function', function() - local gcal = require('pending.sync.gcal') - assert.are.equal('function', type(gcal.push)) - end) - - it('has health function', function() - local gcal = require('pending.sync.gcal') - assert.are.equal('function', type(gcal.health)) - end) - - it('has auth function', function() - local gcal = require('pending.sync.gcal') - assert.are.equal('function', type(gcal.auth)) - end) - - it('has auth_complete function', function() - local gcal = require('pending.sync.gcal') - local completions = gcal.auth_complete() - assert.is_true(vim.tbl_contains(completions, 'clear')) - assert.is_true(vim.tbl_contains(completions, 'reset')) - end) - end) - - describe('auto-discovery', function() - it('discovers gcal and gtasks backends', function() - local backends = pending.sync_backends() - assert.is_true(vim.tbl_contains(backends, 'gcal')) - assert.is_true(vim.tbl_contains(backends, 'gtasks')) - end) - - it('excludes modules without name field', function() - local set = pending.sync_backend_set() - assert.is_nil(set['oauth']) - assert.is_nil(set['util']) - end) - - it('populates backend set correctly', function() - local set = pending.sync_backend_set() - assert.is_true(set['gcal'] == true) - assert.is_true(set['gtasks'] == true) - end) - end) - - describe('auth dispatch', function() - it('routes auth to specific backend', function() - local called_with = nil - local gcal = require('pending.sync.gcal') - local orig_auth = gcal.auth - gcal.auth = function(args) - called_with = args or 'default' - end - pending.auth('gcal') - gcal.auth = orig_auth - assert.are.equal('default', called_with) - end) - - it('routes auth with sub-action', function() - local called_with = nil - local gcal = require('pending.sync.gcal') - local orig_auth = gcal.auth - gcal.auth = function(args) - called_with = args - end - pending.auth('gcal clear') - gcal.auth = orig_auth - assert.are.equal('clear', called_with) - end) - - it('errors on unknown backend', function() - local msg - local orig = vim.notify - vim.notify = function(m, level) - if level == vim.log.levels.ERROR then - msg = m - end - end - pending.auth('nonexistent') - vim.notify = orig - assert.truthy(msg and msg:find('No auth method')) - end) - end) -end) diff --git a/spec/sync_util_spec.lua b/spec/sync_util_spec.lua deleted file mode 100644 index d3660fb..0000000 --- a/spec/sync_util_spec.lua +++ /dev/null @@ -1,100 +0,0 @@ -require('spec.helpers') - -local config = require('pending.config') -local util = require('pending.sync.util') - -describe('sync util', function() - before_each(function() - config.reset() - end) - - after_each(function() - config.reset() - end) - - describe('fmt_counts', function() - it('returns nothing to do for empty counts', function() - assert.equals('nothing to do', util.fmt_counts({})) - end) - - it('returns nothing to do when all zero', function() - assert.equals('nothing to do', util.fmt_counts({ { 0, 'added' }, { 0, 'failed' } })) - end) - - it('formats single non-zero count', function() - assert.equals('3 added', util.fmt_counts({ { 3, 'added' }, { 0, 'failed' } })) - end) - - it('joins multiple non-zero counts with pipe', function() - local result = util.fmt_counts({ { 2, 'added' }, { 1, 'updated' }, { 0, 'failed' } }) - assert.equals('2 added | 1 updated', result) - end) - end) - - describe('with_guard', function() - it('prevents concurrent calls', function() - local inner_called = false - local blocked = false - - local msgs = {} - local orig = vim.notify - vim.notify = function(m, level) - if level == vim.log.levels.WARN then - table.insert(msgs, m) - end - end - - util.with_guard('test', function() - inner_called = true - util.with_guard('test2', function() - blocked = true - end) - end) - - vim.notify = orig - assert.is_true(inner_called) - assert.is_false(blocked) - assert.equals(1, #msgs) - assert.truthy(msgs[1]:find('Sync already in progress')) - end) - - it('clears guard after error', function() - pcall(util.with_guard, 'err-test', function() - error('boom') - end) - - assert.is_false(util.sync_in_flight()) - end) - - it('clears guard after success', function() - util.with_guard('ok-test', function() end) - assert.is_false(util.sync_in_flight()) - end) - end) - - describe('finish', function() - it('calls save and recompute', function() - local helpers = require('spec.helpers') - local store_mod = require('pending.store') - local tmpdir = helpers.tmpdir() - vim.g.pending = { data_path = tmpdir .. '/tasks.json' } - config.reset() - package.loaded['pending'] = nil - - local s = store_mod.new(tmpdir .. '/tasks.json') - s:load() - s:add({ description = 'Test', status = 'pending', category = 'Work', priority = 0 }) - - util.finish(s) - - local reloaded = store_mod.new(tmpdir .. '/tasks.json') - reloaded:load() - assert.equals(1, #reloaded:tasks()) - - vim.fn.delete(tmpdir, 'rf') - vim.g.pending = nil - config.reset() - package.loaded['pending'] = nil - end) - end) -end) diff --git a/spec/textobj_spec.lua b/spec/textobj_spec.lua deleted file mode 100644 index 1253f58..0000000 --- a/spec/textobj_spec.lua +++ /dev/null @@ -1,194 +0,0 @@ -require('spec.helpers') - -local config = require('pending.config') - -describe('textobj', function() - local textobj = require('pending.textobj') - - before_each(function() - vim.g.pending = nil - config.reset() - end) - - after_each(function() - vim.g.pending = nil - config.reset() - end) - - describe('inner_task_range', function() - it('returns description range for task with id prefix', function() - local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries') - assert.are.equal(10, s) - assert.are.equal(22, e) - end) - - it('returns description range for task without id prefix', function() - local s, e = textobj.inner_task_range('- [ ] Buy groceries') - assert.are.equal(7, s) - assert.are.equal(19, e) - end) - - it('excludes trailing due: token', function() - local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries due:2026-03-15') - assert.are.equal(10, s) - assert.are.equal(22, e) - end) - - it('excludes trailing cat: token', function() - local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries cat:Errands') - assert.are.equal(10, s) - assert.are.equal(22, e) - end) - - it('excludes trailing rec: token', function() - local s, e = textobj.inner_task_range('/1/- [ ] Take out trash rec:weekly') - assert.are.equal(10, s) - assert.are.equal(23, e) - end) - - it('excludes multiple trailing metadata tokens', function() - local s, e = - textobj.inner_task_range('/1/- [ ] Buy milk due:2026-03-15 cat:Errands rec:weekly') - assert.are.equal(10, s) - assert.are.equal(17, e) - end) - - it('handles priority checkbox', function() - local s, e = textobj.inner_task_range('/1/- [!] Important task') - assert.are.equal(10, s) - assert.are.equal(23, e) - end) - - it('handles done checkbox', function() - local s, e = textobj.inner_task_range('/1/- [x] Finished task') - assert.are.equal(10, s) - assert.are.equal(22, e) - end) - - it('handles multi-digit task ids', function() - local s, e = textobj.inner_task_range('/123/- [ ] Some task') - assert.are.equal(12, s) - assert.are.equal(20, e) - end) - - it('does not strip non-metadata tokens', function() - local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries for dinner') - assert.are.equal(10, s) - assert.are.equal(33, e) - end) - - it('stops stripping at first non-metadata token from right', function() - local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries for dinner due:2026-03-15') - assert.are.equal(10, s) - assert.are.equal(33, e) - end) - - it('respects custom date_syntax', function() - vim.g.pending = { date_syntax = 'by' } - config.reset() - local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries by:2026-03-15') - assert.are.equal(10, s) - assert.are.equal(22, e) - end) - - it('respects custom recur_syntax', function() - vim.g.pending = { recur_syntax = 'repeat' } - config.reset() - local s, e = textobj.inner_task_range('/1/- [ ] Take trash repeat:weekly') - assert.are.equal(10, s) - assert.are.equal(19, e) - end) - - it('handles task with only metadata after description', function() - local s, e = textobj.inner_task_range('/1/- [ ] X due:tomorrow') - assert.are.equal(10, s) - assert.are.equal(10, e) - end) - end) - - describe('category_bounds', function() - it('returns header and last row for single category', function() - ---@type pending.LineMeta[] - local meta = { - { type = 'header', category = 'Work' }, - { type = 'task', id = 1 }, - { type = 'task', id = 2 }, - } - local h, l = textobj.category_bounds(2, meta) - assert.are.equal(1, h) - assert.are.equal(3, l) - end) - - it('returns bounds for first category with trailing blank', function() - ---@type pending.LineMeta[] - local meta = { - { type = 'header', category = 'Work' }, - { type = 'task', id = 1 }, - { type = 'blank' }, - { type = 'header', category = 'Personal' }, - { type = 'task', id = 2 }, - } - local h, l = textobj.category_bounds(2, meta) - assert.are.equal(1, h) - assert.are.equal(3, l) - end) - - it('returns bounds for second category', function() - ---@type pending.LineMeta[] - local meta = { - { type = 'header', category = 'Work' }, - { type = 'task', id = 1 }, - { type = 'blank' }, - { type = 'header', category = 'Personal' }, - { type = 'task', id = 2 }, - { type = 'task', id = 3 }, - } - local h, l = textobj.category_bounds(5, meta) - assert.are.equal(4, h) - assert.are.equal(6, l) - end) - - it('returns bounds when cursor is on header', function() - ---@type pending.LineMeta[] - local meta = { - { type = 'header', category = 'Work' }, - { type = 'task', id = 1 }, - } - local h, l = textobj.category_bounds(1, meta) - assert.are.equal(1, h) - assert.are.equal(2, l) - end) - - it('returns nil for blank line with no preceding header', function() - ---@type pending.LineMeta[] - local meta = { - { type = 'blank' }, - { type = 'header', category = 'Work' }, - { type = 'task', id = 1 }, - } - local h, l = textobj.category_bounds(1, meta) - assert.is_nil(h) - assert.is_nil(l) - end) - - it('returns nil for empty meta', function() - local h, l = textobj.category_bounds(1, {}) - assert.is_nil(h) - assert.is_nil(l) - end) - - it('includes blank between header and next header in bounds', function() - ---@type pending.LineMeta[] - local meta = { - { type = 'header', category = 'Work' }, - { type = 'task', id = 1 }, - { type = 'blank' }, - { type = 'header', category = 'Home' }, - { type = 'task', id = 2 }, - } - local h, l = textobj.category_bounds(1, meta) - assert.are.equal(1, h) - assert.are.equal(3, l) - end) - end) -end) diff --git a/spec/views_spec.lua b/spec/views_spec.lua index ff8ad93..4d91e06 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -5,38 +5,39 @@ 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() - s = store.new(tmpdir .. '/tasks.json') - s:load() + store.unload() + store.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() - 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]) + 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]) 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 = 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 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 pending_row, done_row for i, m in ipairs(meta) do if m.type == 'task' and m.status == 'pending' then @@ -49,9 +50,9 @@ describe('views', function() end) it('sorts high-priority tasks before normal tasks within pending group', function() - 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()) + 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()) local high_row, normal_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -67,11 +68,11 @@ describe('views', function() end) it('sorts high-priority tasks before normal tasks within done group', function() - 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 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 high_row, normal_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -87,9 +88,9 @@ describe('views', function() end) it('gives each category its own header with blank lines between them', function() - s:add({ description = 'Task A', category = 'Work' }) - s:add({ description = 'Task B', category = 'Personal' }) - local lines, meta = views.category_view(s:active_tasks()) + store.add({ description = 'Task A', category = 'Work' }) + store.add({ description = 'Task B', category = 'Personal' }) + local lines, meta = views.category_view(store.active_tasks()) local headers = {} local blank_found = false for i, m in ipairs(meta) do @@ -104,8 +105,8 @@ describe('views', function() end) it('formats task lines as /ID/ description', function() - s:add({ description = 'My task', category = 'Inbox' }) - local lines, meta = views.category_view(s:active_tasks()) + store.add({ description = 'My task', category = 'Inbox' }) + local lines, meta = views.category_view(store.active_tasks()) local task_line for i, m in ipairs(meta) do if m.type == 'task' then @@ -116,8 +117,8 @@ describe('views', function() end) it('formats priority task lines as /ID/- [!] description', function() - s:add({ description = 'Important', category = 'Inbox', priority = 1 }) - local lines, meta = views.category_view(s:active_tasks()) + store.add({ description = 'Important', category = 'Inbox', priority = 1 }) + local lines, meta = views.category_view(store.active_tasks()) local task_line for i, m in ipairs(meta) do if m.type == 'task' then @@ -128,15 +129,15 @@ describe('views', function() end) it('sets LineMeta type=header for header lines with correct category', function() - s:add({ description = 'T', category = 'School' }) - local _, meta = views.category_view(s:active_tasks()) + store.add({ description = 'T', category = 'School' }) + local _, meta = views.category_view(store.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 = s:add({ description = 'Do something', category = 'Inbox' }) - local _, meta = views.category_view(s:active_tasks()) + local t = store.add({ description = 'Do something', category = 'Inbox' }) + local _, meta = views.category_view(store.active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' then @@ -149,9 +150,9 @@ describe('views', function() end) it('sets LineMeta type=blank for blank separator lines', function() - s:add({ description = 'A', category = 'Work' }) - s:add({ description = 'B', category = 'Home' }) - local _, meta = views.category_view(s:active_tasks()) + store.add({ description = 'A', category = 'Work' }) + store.add({ description = 'B', category = 'Home' }) + local _, meta = views.category_view(store.active_tasks()) local blank_meta for _, m in ipairs(meta) do if m.type == 'blank' then @@ -165,8 +166,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 = s:add({ description = 'Overdue task', category = 'Inbox', due = yesterday }) - local _, meta = views.category_view(s:active_tasks()) + local t = store.add({ description = 'Overdue task', category = 'Inbox', due = yesterday }) + local _, meta = views.category_view(store.active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -178,8 +179,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 = s:add({ description = 'Future task', category = 'Inbox', due = tomorrow }) - local _, meta = views.category_view(s:active_tasks()) + local t = store.add({ description = 'Future task', category = 'Inbox', due = tomorrow }) + local _, meta = views.category_view(store.active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -191,9 +192,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 = s:add({ description = 'Done late', category = 'Inbox', due = yesterday }) - s:update(t.id, { status = 'done' }) - local _, meta = views.category_view(s:active_tasks()) + 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 task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -203,39 +204,12 @@ 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', - view = { category = { order = { 'Work', 'Inbox' } } }, - } + vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work', 'Inbox' } } config.reset() - s:add({ description = 'Inbox task', category = 'Inbox' }) - s:add({ description = 'Work task', category = 'Work' }) - local lines, meta = views.category_view(s:active_tasks()) + store.add({ description = 'Inbox task', category = 'Inbox' }) + store.add({ description = 'Work task', category = 'Work' }) + local lines, meta = views.category_view(store.active_tasks()) local first_header, second_header for i, m in ipairs(meta) do if m.type == 'header' then @@ -246,48 +220,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', view = { category = { order = { 'Work' } } } } + vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work' } } config.reset() - s:add({ description = 'Errand', category = 'Errands' }) - s:add({ description = 'Work task', category = 'Work' }) - local lines, meta = views.category_view(s:active_tasks()) + store.add({ description = 'Errand', category = 'Errands' }) + store.add({ description = 'Work task', category = 'Work' }) + local lines, meta = views.category_view(store.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() - s:add({ description = 'Alpha task', category = 'Alpha' }) - s:add({ description = 'Beta task', category = 'Beta' }) - local lines, meta = views.category_view(s:active_tasks()) + store.add({ description = 'Alpha task', category = 'Alpha' }) + store.add({ description = 'Beta task', category = 'Beta' }) + local lines, meta = views.category_view(store.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 = 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 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 last_pending_row, first_done_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -302,9 +275,9 @@ describe('views', function() end) it('sorts pending tasks by priority desc within pending group', function() - 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()) + 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()) local high_row, low_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -319,9 +292,9 @@ describe('views', function() end) it('sorts pending tasks with due dates before those without', function() - 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()) + 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()) local due_row, nodue_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -336,9 +309,9 @@ describe('views', function() end) it('sorts pending tasks with earlier due dates before later due dates', function() - 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()) + 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()) local earlier_row, later_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -353,15 +326,15 @@ describe('views', function() end) it('formats task lines as /ID/- [ ] description', function() - s:add({ description = 'My task', category = 'Inbox' }) - local lines, _ = views.priority_view(s:active_tasks()) + store.add({ description = 'My task', category = 'Inbox' }) + local lines, _ = views.priority_view(store.active_tasks()) assert.are.equal('/1/- [ ] My task', lines[1]) end) it('sets show_category=true for all task meta entries', function() - s:add({ description = 'T1', category = 'Work' }) - s:add({ description = 'T2', category = 'Personal' }) - local _, meta = views.priority_view(s:active_tasks()) + store.add({ description = 'T1', category = 'Work' }) + store.add({ description = 'T2', category = 'Personal' }) + local _, meta = views.priority_view(store.active_tasks()) for _, m in ipairs(meta) do if m.type == 'task' then assert.is_true(m.show_category == true) @@ -370,9 +343,9 @@ describe('views', function() end) it('sets meta.category correctly for each task', function() - s:add({ description = 'Work task', category = 'Work' }) - s:add({ description = 'Home task', category = 'Home' }) - local lines, meta = views.priority_view(s:active_tasks()) + store.add({ description = 'Work task', category = 'Work' }) + store.add({ description = 'Home task', category = 'Home' }) + local lines, meta = views.priority_view(store.active_tasks()) local categories = {} for i, m in ipairs(meta) do if m.type == 'task' then @@ -389,8 +362,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 = s:add({ description = 'Overdue', category = 'Inbox', due = yesterday }) - local _, meta = views.priority_view(s:active_tasks()) + local t = store.add({ description = 'Overdue', category = 'Inbox', due = yesterday }) + local _, meta = views.priority_view(store.active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -402,8 +375,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 = s:add({ description = 'Future', category = 'Inbox', due = tomorrow }) - local _, meta = views.priority_view(s:active_tasks()) + local t = store.add({ description = 'Future', category = 'Inbox', due = tomorrow }) + local _, meta = views.priority_view(store.active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -415,9 +388,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 = s:add({ description = 'Done late', category = 'Inbox', due = yesterday }) - s:update(t.id, { status = 'done' }) - local _, meta = views.priority_view(s:active_tasks()) + 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 task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -426,29 +399,5 @@ 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)