diff --git a/.gitignore b/.gitignore index 93ac2c5..7cdfb66 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ doc/tags *.log +minimal_init.lua .*cache* CLAUDE.md diff --git a/.luarc.json b/.luarc.json index 23646d3..c8eaaf9 100644 --- a/.luarc.json +++ b/.luarc.json @@ -2,7 +2,14 @@ "runtime.version": "LuaJIT", "runtime.path": ["lua/?.lua", "lua/?/init.lua"], "diagnostics.globals": ["vim", "jit"], - "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], + "diagnostics.libraryFiles": "Disable", + "workspace.library": [ + "$VIMRUNTIME/lua", + "${3rd}/luv/library", + "${3rd}/busted/library", + "${3rd}/luassert/library" + ], "workspace.checkThirdParty": false, + "workspace.ignoreDir": [".direnv"], "completion.callSnippet": "Replace" } diff --git a/README.md b/README.md index df7f3dd..3448941 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,17 @@ # pending.nvim -Edit tasks like text. `:w` saves them. +**Edit tasks like text.** - +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` and `openssl` for Google Calendar and Google Task sync +- (Optionally) `curl` for Google Calendar and Google Task sync ## Installation diff --git a/doc/pending.txt b/doc/pending.txt index 4eb8e40..f8c61a9 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -30,21 +30,51 @@ concealed tokens and are never visible during editing. Features: ~ - Oil-style buffer editing: standard Vim motions for all task operations -- Inline metadata syntax: `due:` and `cat:` tokens parsed on `:w` -- Relative date input: `today`, `tomorrow`, `+Nd`, weekday names -- Two views: category (default) and priority flat list -- Multi-level undo (up to 20 `:w` saves, session-only) +- Inline metadata syntax: `due:`, `cat:`, and `rec:` tokens parsed on `:w` +- Relative date input: `today`, `tomorrow`, `+Nd`, `+Nw`, `+Nm`, weekday + names, month names, ordinals, and more +- Recurring tasks with automatic next-date spawning on completion +- Two views: category (default) and queue (priority-sorted flat list) +- Multi-level undo (up to 20 `:w` saves, persisted across sessions) - Quick-add from the command line with `:Pending add` - Quickfix list of overdue/due-today tasks via `:Pending due` -- Foldable category sections (`zc`/`zo`) in category view +- Configurable category folds (`zc`/`zo`) with custom foldtext +- Omnifunc completion for `cat:`, `due:`, and `rec:` tokens (``) - Google Calendar one-way push via OAuth PKCE +- Google Tasks bidirectional sync via OAuth PKCE + +============================================================================== +CONTENTS *pending-contents* + + 1. Introduction ............................................. |pending.nvim| + 2. Requirements ..................................... |pending-requirements| + 3. Install ............................................... |pending-install| + 4. Usage ................................................... |pending-usage| + 5. Commands .............................................. |pending-commands| + 6. Mappings .............................................. |pending-mappings| + 7. Views ................................................... |pending-views| + 8. Filters ............................................... |pending-filters| + 9. Inline Metadata ....................................... |pending-metadata| + 10. Date Input .............................................. |pending-dates| + 11. Recurrence ......................................... |pending-recurrence| + 12. Configuration ........................................... |pending-config| + 13. Store Resolution .......................... |pending-store-resolution| + 14. Highlight Groups .................................... |pending-highlights| + 15. Lua API ................................................... |pending-api| + 16. Recipes ............................................... |pending-recipes| + 17. Sync Backends ................................... |pending-sync-backend| + 18. Google Calendar .......................................... |pending-gcal| + 19. Google Tasks ............................................ |pending-gtasks| + 20. Google Authentication ......................... |pending-google-auth| + 21. Data Format .............................................. |pending-data| + 22. Health Check ........................................... |pending-health| ============================================================================== REQUIREMENTS *pending-requirements* - Neovim 0.10+ - No external dependencies for local use -- `curl` and `openssl` are required for Google Calendar sync +- `curl` is required for the `gcal` and `gtasks` sync backends ============================================================================== INSTALL *pending-install* @@ -86,39 +116,6 @@ persists across window switches; reopening with `:Pending` focuses the existing window if one is open. The buffer is automatically reloaded from disk when entered unmodified. -============================================================================== -INLINE METADATA *pending-metadata* - -Metadata tokens may be appended to any task line before saving. Tokens are -parsed from the right and consumed until a non-metadata token is reached. - -Supported tokens: ~ - - `due:YYYY-MM-DD` Set a due date using an absolute date. - `due:today` Resolve to today's date. - `due:tomorrow` Resolve to tomorrow's date. - `due:+Nd` Resolve to N days from today (e.g. `due:+3d`). - `due:mon` Resolve to the next occurrence of that weekday. - Supported: `sun` `mon` `tue` `wed` `thu` `fri` `sat` - `cat:Name` Move the task to the named category on save. - -The token name for due dates defaults to `due` and is configurable via -`date_syntax` in |pending-config|. If `date_syntax` is set to `by`, write -`by:2026-03-15` instead. - -Example: > - - Buy milk due:2026-03-15 cat:Errands -< - -On `:w`, the description becomes `Buy milk`, the due date is stored as -`2026-03-15` and rendered as right-aligned virtual text, and the task is -placed under the `Errands` category header. - -Parsing stops at the first token that is not a recognised metadata token. -Repeated tokens of the same type also stop parsing — only one `due:` and one -`cat:` per task line are consumed. - ============================================================================== COMMANDS *pending-commands* @@ -135,6 +132,7 @@ COMMANDS *pending-commands* :Pending add Buy groceries due:2026-03-15 :Pending add School: Submit homework :Pending add Errands: Pick up dry cleaning due:fri + :Pending add Work: standup due:tomorrow rec:weekdays < If the buffer is currently open it is re-rendered after the add. @@ -151,17 +149,108 @@ COMMANDS *pending-commands* Populate the quickfix list with all tasks that are overdue or due today. Open the list with |:copen| to navigate to each task's category. - *:Pending-sync* -:Pending sync - Push pending tasks that have a due date to Google Calendar as all-day - events. Requires |pending-gcal| to be configured. See |pending-gcal| for - full details on what gets created, updated, and deleted. + *:Pending-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 without opening the buffer. {id} is the + numeric task ID. One or more operations follow: >vim + :Pending edit 5 due:tomorrow cat:Work +! + :Pending edit 5 -due -cat -rec + :Pending edit 5 rec:!weekly due:fri +< + Operations: ~ + `due:` Set due date (accepts all |pending-dates| vocabulary). + `cat:` Set category. + `rec:` Set recurrence (prefix `!` for completion-based). + `+!` Add priority flag. + `-!` Remove priority flag. + `-due` Clear due date. + `-cat` Clear category. + `-rec` Clear recurrence. + + Tab completion is available for IDs, field names, date values, categories, + and recurrence patterns. *:Pending-undo* :Pending undo Undo the last `:w` save, restoring the task store to its previous state. Equivalent to the `U` buffer-local key (see |pending-mappings|). Up to 20 - levels of undo are retained per session. + levels of undo are persisted across sessions. + + *:Pending-init* +:Pending init + Create a project-local `.pending.json` file in the current working + directory. After creation, `:Pending` will use this file instead of the + global store (see |pending-store-resolution|). Errors if `.pending.json` + already exists in the current directory. + + *:PendingTab* +:PendingTab + Open the task buffer in a new tab. ============================================================================== MAPPINGS *pending-mappings* @@ -169,27 +258,63 @@ MAPPINGS *pending-mappings* The following keys are set buffer-locally when the task buffer opens. They are active only in the `pending://` buffer. -Buffer-local keys: ~ +Buffer-local keys are configured via the `keymaps` table in |pending-config|. +The defaults are shown below. Set any key to `false` to disable it. + +Default buffer-local keys: ~ Key Action ~ ------- ------------------------------------------------ - `` Toggle complete / uncomplete the task at cursor - `!` Toggle the priority flag on the task at cursor - `D` Prompt for a due date on the task at cursor - `` Switch between category view and priority view - `U` Undo the last `:w` save - `g?` Show a help popup with available keys - `zc` Fold the current category section (category view only) - `zo` Unfold the current category section (category view only) + `q` Close the task buffer (`close`) + `` Toggle complete / uncomplete (`toggle`) + `!` Toggle the priority flag (`priority`) + `D` Prompt for a due date (`date`) + `F` Prompt for filter predicates (`filter`) + `` Switch between category / queue view (`view`) + `U` Undo the last `:w` save (`undo`) + `o` Insert a new task line below (`open_line`) + `O` Insert a new task line above (`open_line_above`) + `zc` Fold the current category section (requires `folding`) + `zo` Unfold the current category section (requires `folding`) -`o` and `O` are overridden to insert a correctly-formatted blank task line -at the position below or above the cursor rather than using standard Vim -indentation. `dd`, `p`, `P`, and `:w` work as expected. +Text objects (operator-pending and visual): ~ + + Key Action ~ + ------- ------------------------------------------------ + `at` Select the current task line (`a_task`) + `it` Select the task description only (`i_task`) + `aC` Select a category: header + tasks + blanks (`a_category`) + `iC` Select inner category: tasks only (`i_category`) + +`at` supports count: `d3at` deletes three consecutive tasks. `it` selects +the description text between the checkbox prefix and trailing metadata +tokens (`due:`, `cat:`, `rec:`), making `cit` the natural way to retype a +task description without touching its metadata. + +`aC` and `iC` are no-ops in the queue view (no headers to delimit). + +Motions (normal, visual, operator-pending): ~ + + Key Action ~ + ------- ------------------------------------------------ + `]]` Jump to the next category header (`next_header`) + `[[` Jump to the previous category header (`prev_header`) + `]t` Jump to the next task line (`next_task`) + `[t` Jump to the previous task line (`prev_task`) + +All motions support count: `3]]` jumps three headers forward. `]]` and +`[[` are no-ops in the queue view. `]t` and `[t` work in both views. + +`dd`, `p`, `P`, and `:w` work as standard Vim operations. *(pending-open)* (pending-open) Open the task buffer. Maps to |:Pending| with no arguments. + *(pending-close)* +(pending-close) + Close the task buffer window. + *(pending-toggle)* (pending-toggle) Toggle complete / uncomplete for the task under the cursor. @@ -206,6 +331,57 @@ indentation. `dd`, `p`, `P`, and `:w` work as expected. (pending-view) Switch between category view and priority view. + *(pending-undo)* +(pending-undo) + Undo the last `:w` save. + + *(pending-filter)* +(pending-filter) + Prompt for filter predicates via |vim.ui.input|. + + *(pending-open-line)* +(pending-open-line) + Insert a correctly-formatted blank task line below the cursor. + + *(pending-open-line-above)* +(pending-open-line-above) + Insert a correctly-formatted blank task line above the cursor. + + *(pending-a-task)* +(pending-a-task) + Select the current task line (linewise). Supports count. + + *(pending-i-task)* +(pending-i-task) + Select the task description text (characterwise). + + *(pending-a-category)* +(pending-a-category) + Select a full category section: header, tasks, and surrounding blanks. + + *(pending-i-category)* +(pending-i-category) + Select tasks within a category, excluding the header and blanks. + + *(pending-next-header)* +(pending-next-header) + Jump to the next category header. Supports count. + + *(pending-prev-header)* +(pending-prev-header) + Jump to the previous category header. Supports count. + + *(pending-next-task)* +(pending-next-task) + Jump to the next task line, skipping headers and blanks. + + *(pending-prev-task)* +(pending-prev-task) + Jump to the previous task line, skipping headers and blanks. + +(pending-tab) *(pending-tab)* + Open the task buffer in a new tab. See |:PendingTab|. + Example configuration: >lua vim.keymap.set('n', 't', '(pending-open)') vim.keymap.set('n', 'T', '(pending-toggle)') @@ -224,12 +400,187 @@ Category view (default): ~ *pending-view-category* first within each group. Category sections are foldable with `zc` and `zo`. -Priority view: ~ *pending-view-priority* +Queue view: ~ *pending-view-queue* A flat list of all tasks sorted by priority, then by due date (tasks without a due date sort last), then by internal order. Done tasks appear after all pending tasks. Category names are shown as right-aligned virtual text alongside the due date virtual text so tasks remain identifiable - across categories. + across categories. The buffer is named `pending://queue`. + +============================================================================== +FILTERS *pending-filters* + +Filters narrow the task buffer to a subset of tasks without deleting any data. +Hidden tasks are preserved in the store and reappear when the filter is +cleared. Filter state is session-local — it does not persist across Neovim +restarts. + +Set a filter with |:Pending-filter|, the `F` buffer key, or by editing the +`FILTER:` line: >vim + :Pending filter cat:Work overdue +< + +Multiple predicates are separated by spaces and combined with AND logic — a +task must match every predicate to be shown. + +Available predicates: ~ + + `cat:X` Show only tasks whose category is exactly `X`. Tasks with no + category (assigned to `default_category`) are hidden unless + `default_category` matches `X`. + + `overdue` Show only pending tasks with a due date strictly before today. + + `today` Show only pending tasks with a due date equal to today. + + `priority` Show only tasks with priority > 0 (the `!` marker). + + `clear` Special value for |:Pending-filter| — clears the active filter + and shows all tasks. + +FILTER: line: ~ *pending-filter-line* + +When a filter is active, the first line of the task buffer is: > + FILTER: cat:Work overdue +< + +This line is editable. Write the buffer with `:w` to apply the updated +predicates. Deleting the `FILTER:` line and saving clears the filter. The +line is highlighted with |PendingFilter| and does not appear in the stored +task data. + +============================================================================== +INLINE METADATA *pending-metadata* + +Metadata tokens may be appended to any task line before saving. Tokens are +parsed from the right and consumed until a non-metadata token is reached. + +Supported tokens: ~ + + `due:YYYY-MM-DD` Set a due date using an absolute date. + `due:` Resolve a named date (see |pending-dates| below). + `cat:Name` Move the task to the named category on save. + `rec:` Set a recurrence rule (see |pending-recurrence|). + +The token name for due dates defaults to `due` and is configurable via +`date_syntax` in |pending-config|. The token name for recurrence defaults to +`rec` and is configurable via `recur_syntax`. + +Example: > + + Buy milk due:2026-03-15 cat:Errands + Take out trash due:monday rec:weekly +< + +On `:w`, the description becomes `Buy milk`, the due date is stored as +`2026-03-15` and rendered as right-aligned virtual text, and the task is +placed under the `Errands` category header. + +Parsing stops at the first token that is not a recognised metadata token. +Repeated tokens of the same type also stop parsing — only one `due:`, one +`cat:`, and one `rec:` per task line are consumed. + +Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types. +In insert mode, type the token prefix and press `` to see +suggestions. + +============================================================================== +DATE INPUT *pending-dates* + +Named dates can be used anywhere a date is accepted: the `due:` inline +token, the `D` prompt, and `:Pending add`. + + Token Resolves to ~ + ----- ----------- + `today` Today's date + `tomorrow` Tomorrow's date + `yesterday` Yesterday's date + `eod` Today (end of day semantics) + `+Nd` N days from today (e.g. `+3d`) + `+Nw` N weeks from today (e.g. `+2w`) + `+Nm` N months from today (e.g. `+1m`) + `-Nd` N days ago (e.g. `-2d`) + `-Nw` N weeks ago (e.g. `-1w`) + `mon`–`sun` Next occurrence of that weekday + `jan`–`dec` 1st of next occurrence of that month + `1st`–`31st` Next occurrence of that day-of-month + `sow` / `eow` Monday / Sunday of current week + `som` / `eom` First / last day of current month + `soq` / `eoq` First / last day of current quarter + `soy` / `eoy` January 1 / December 31 of current year + `later` / `someday` Sentinel date (default: `9999-12-30`) + +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). ============================================================================== CONFIGURATION *pending-config* @@ -238,14 +589,42 @@ 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_view = 'category', - default_category = 'Inbox', + default_category = 'Todo', date_format = '%b %d', date_syntax = 'due', - category_order = {}, - gcal = { - calendar = 'Tasks', - credentials_path = '/path/to/client_secret.json', + recur_syntax = 'rec', + someday_date = '9999-12-30', + view = { + default = 'category', + eol_format = '%c %r %d', + category = { + order = {}, + folding = true, + }, + queue = {}, + }, + keymaps = { + close = 'q', + toggle = '', + view = '', + priority = '!', + date = 'D', + undo = 'U', + filter = 'F', + open_line = 'o', + open_line_above = 'O', + a_task = 'at', + i_task = 'it', + a_category = 'aC', + i_category = 'iC', + next_header = ']]', + prev_header = '[[', + next_task = ']t', + prev_task = '[t', + }, + sync = { + gcal = {}, + gtasks = {}, }, } < @@ -255,15 +634,13 @@ All fields are optional. Unset fields use the defaults shown above. *pending.Config* Fields: ~ {data_path} (string) - Path to the JSON file where tasks are stored. + Path to the global JSON file where tasks are stored. Default: `stdpath('data') .. '/pending/tasks.json'`. The directory is created automatically on first save. + See |pending-store-resolution| for how the active + store is chosen at runtime. - {default_view} ('category'|'priority', default: 'category') - The view to use when the buffer is opened for the - first time in a session. - - {default_category} (string, default: 'Inbox') + {default_category} (string, default: 'Todo') Category assigned to new tasks when no `cat:` token is present and no `Category: ` prefix is used with `:Pending add`. @@ -273,75 +650,149 @@ 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`. - {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. + {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`. - {gcal} (table, default: nil) - Google Calendar sync configuration. See - |pending.GcalConfig|. Omit this field entirely to - disable Google Calendar sync. + {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. -============================================================================== -GOOGLE CALENDAR *pending-gcal* + {view} (table) *pending.ViewConfig* + View rendering configuration. Groups all settings + that affect how the buffer displays tasks. -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. + {default} ('category'|'priority', default: 'category') + The view to use when the buffer is opened + for the first time in a session. -Configuration: >lua - vim.g.pending = { - gcal = { - calendar = 'Tasks', - credentials_path = '/path/to/client_secret.json', - }, - } + {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' }, + }, + }, + } < - *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. + {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. - {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. + {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 } +< -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. + {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`. -`: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. + {icons} (table) *pending.Icons* + Icon characters displayed in the buffer. The + {pending}, {done}, and {priority} characters + appear inside brackets (`[icon]`) as an overlay + on the checkbox. The {category} character + prefixes both header lines and EOL category + labels. Fields: + {pending} Pending task character. Default: ' ' + {done} Done task character. Default: 'x' + {priority} Priority task character. Default: '!' + {due} Due date prefix. Default: '.' + {recur} Recurrence prefix. Default: '~' + {category} Category prefix. Default: '#' -A summary notification is shown after sync: `created: N, updated: N, -deleted: N`. +============================================================================== +STORE RESOLUTION *pending-store-resolution* + +When pending.nvim opens the task buffer it resolves which store file to use: + +1. Search upward from `vim.fn.getcwd()` for a file named `.pending.json`. +2. If found, use that file as the active store (project-local store). +3. If not found, fall back to `data_path` from |pending-config| (global + store). + +This means placing a `.pending.json` file in a project root makes that +project use an isolated task list. Tasks in the project store are completely +separate from tasks in the global store; there is no aggregation. + +To create a project-local store in the current directory: >vim + :Pending init +< + +The `:checkhealth pending` report shows which store file is currently active. ============================================================================== HIGHLIGHT GROUPS *pending-highlights* @@ -369,6 +820,16 @@ PendingDone Applied to the text of completed tasks. *PendingPriority* PendingPriority Applied to the `! ` priority marker on priority tasks. + Default: links to `DiagnosticWarn`. + + *PendingRecur* +PendingRecur Applied to the recurrence indicator virtual text shown + alongside due dates for recurring tasks. + Default: links to `DiagnosticInfo`. + + *PendingFilter* +PendingFilter Applied to the `FILTER:` header line shown at the top of + the buffer when a filter is active. Default: links to `DiagnosticWarn`. To override a group in your colorscheme or config: >lua @@ -376,25 +837,337 @@ To override a group in your colorscheme or config: >lua < ============================================================================== -HEALTH CHECK *pending-health* +LUA API *pending-api* -Run |:checkhealth| pending to verify your setup: >vim - :checkhealth pending +The following functions are available on `require('pending')` for use in +statuslines, autocmds, and other integrations. + + *pending.counts()* +pending.counts() + Returns a table of current task counts: >lua + { + overdue = 2, -- pending tasks past their due date/time + today = 1, -- pending tasks due today (not yet overdue) + pending = 10, -- total pending tasks (all statuses) + priority = 3, -- pending tasks with priority > 0 + next_due = "2026-03-01", -- earliest future due date, or nil + } +< + The counts are read from a module-local cache that is invalidated on every + `:w`, toggle, date change, archive, undo, and sync. The first call triggers + a lazy `store.load()` if the store has not been loaded yet. + + Done, deleted, and `someday` sentinel-dated tasks are excluded from the + `overdue` and `today` counts. The `someday` sentinel is the value of + `someday_date` in |pending-config| (default `9999-12-30`). + + *pending.statusline()* +pending.statusline() + Returns a pre-formatted string suitable for embedding in a statusline: + + - `"2 overdue, 1 today"` when both overdue and today counts are non-zero + - `"2 overdue"` when only overdue + - `"1 today"` when only today + - `""` (empty string) when nothing is actionable + + *pending.has_due()* +pending.has_due() + Returns `true` when `overdue > 0` or `today > 0`. Useful as a conditional + for statusline components that should only render when tasks need attention. + + *PendingStatusChanged* +PendingStatusChanged + A |User| autocmd event fired after every count recomputation. Use this to + trigger statusline refreshes or notifications: >lua + vim.api.nvim_create_autocmd('User', { + pattern = 'PendingStatusChanged', + callback = function() + vim.cmd.redrawstatus() + end, + }) < -Checks performed: ~ -- Config loads without error -- Reports active configuration values (data path, default view, default - category, date format, date syntax) -- Whether the data directory exists (warning if not yet created) -- Whether the data file exists and can be parsed; reports total task count -- Whether `curl` is available (required for Google Calendar sync) -- Whether `openssl` is available (required for OAuth PKCE) +============================================================================== +RECIPES *pending-recipes* + +Configure blink.cmp to use pending.nvim's omnifunc as a completion source: >lua + require('blink.cmp').setup({ + sources = { + per_filetype = { + pending = { 'omni', 'buffer' }, + }, + }, + }) +< + +Lualine integration: >lua + require('lualine').setup({ + sections = { + lualine_x = { + { + function() return require('pending').statusline() end, + cond = function() return require('pending').has_due() end, + }, + }, + }, + }) +< + +Heirline integration: >lua + local Pending = { + condition = function() return require('pending').has_due() end, + provider = function() return require('pending').statusline() end, + } +< + +Manual statusline: >vim + set statusline+=%{%v:lua.require('pending').statusline()%} +< + +Startup notification: >lua + vim.api.nvim_create_autocmd('User', { + pattern = 'PendingStatusChanged', + once = true, + callback = function() + local c = require('pending').counts() + if c.overdue > 0 then + vim.notify(c.overdue .. ' overdue task(s)') + end + end, + }) +< + +Event-driven statusline refresh: >lua + vim.api.nvim_create_autocmd('User', { + pattern = 'PendingStatusChanged', + callback = function() + vim.cmd.redrawstatus() + end, + }) +< + +Nerd font icons: >lua + vim.g.pending = { + icons = { + due = '', + recur = '󰁯', + category = '', + }, + } +< + +Open tasks in a new tab on startup: >lua + vim.api.nvim_create_autocmd('VimEnter', { + callback = function() + vim.cmd.PendingTab() + end, + }) +< + +============================================================================== +SYNC BACKENDS *pending-sync-backend* + +Sync backends are Lua modules under `lua/pending/sync/.lua`. Each +backend is exposed as a top-level `:Pending` subcommand: >vim + :Pending gtasks {action} + :Pending gcal {action} +< + +Each module returns a table conforming to the backend interface: >lua + + ---@class pending.SyncBackend + ---@field name string + ---@field 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: ~ + {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). + +Note: authorization is not a per-backend action. Use `:Pending auth` to +authenticate all Google backends at once. See |pending-google-auth|. + +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 ~ +Prompts with |vim.ui.select| offering three options: `gtasks`, `gcal`, and +`both`. All three options run the identical combined OAuth flow — the choice +is informational only. 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. ============================================================================== DATA FORMAT *pending-data* -Tasks are stored as JSON at `data_path`. The file is safe to edit by hand and +Tasks are stored as JSON at the active store path (see +|pending-store-resolution|). The file is safe to edit by hand and is forward-compatible — unknown fields are preserved on every read/write cycle via the `_extra` table. @@ -414,6 +1187,8 @@ Task fields: ~ {category} (string) Category name. Defaults to `default_category`. {priority} (integer) `1` for priority tasks, `0` otherwise. {due} (string) ISO date string `YYYY-MM-DD`, or absent. + {recur} (string) Recurrence shorthand (e.g. `weekly`), or absent. + {recur_mode} (string) `'scheduled'` or `'completion'`, or absent. {entry} (string) ISO 8601 UTC timestamp of creation. {modified} (string) ISO 8601 UTC timestamp of last modification. {end} (string) ISO 8601 UTC timestamp of completion or deletion. @@ -421,7 +1196,8 @@ Task fields: ~ Any field not in the list above is preserved in `_extra` and written back on save. This is used internally to store the Google Calendar event ID -(`_gcal_event_id`) and allows third-party tooling to annotate tasks without +(`_gcal_event_id`) and Google Tasks IDs (`_gtasks_task_id`, +`_gtasks_list_id`), and allows third-party tooling to annotate tasks without data loss. The `version` field is checked on load. If the file version is newer than the @@ -429,4 +1205,10 @@ version the plugin supports, loading is aborted with an error message asking you to update the plugin. ============================================================================== - vim:tw=78:ts=8:ft=help:norl: +HEALTH CHECK *pending-health* + +Run |:checkhealth| pending to verify your setup: >vim + :checkhealth pending +< + +============================================================================== diff --git a/flake.nix b/flake.nix index da16aea..f895154 100644 --- a/flake.nix +++ b/flake.nix @@ -13,9 +13,12 @@ ... }: let - forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); + forEachSystem = + f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); in { + formatter = forEachSystem (pkgs: pkgs.nixfmt-tree); + devShells = forEachSystem (pkgs: { default = pkgs.mkShell { packages = [ diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index d11254b..012dc35 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -1,21 +1,37 @@ local config = require('pending.config') -local store = require('pending.store') +local log = require('pending.log') 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 task_ns = vim.api.nvim_create_namespace('pending') +local ns_eol = vim.api.nvim_create_namespace('pending_eol') +local ns_inline = vim.api.nvim_create_namespace('pending_inline') ---@type 'category'|'priority'|nil 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() @@ -37,12 +53,239 @@ 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', + }) + end + local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' + local bracket_col = (line:find('%[') or 1) - 1 + local icon, icon_hl + if m.status == 'done' then + icon, icon_hl = icons.done, 'PendingDone' + elseif m.priority and m.priority > 0 then + icon, icon_hl = icons.priority, 'PendingPriority' + else + icon, icon_hl = icons.pending, 'Normal' + end + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, bracket_col, { + virt_text = { { '[' .. icon .. ']', icon_hl } }, + virt_text_pos = 'overlay', + priority = 100, + }) + elseif m.type == 'header' then + local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { + end_col = #line, + hl_group = 'PendingHeader', + }) + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { + virt_text = { { icons.category .. ' ', 'PendingHeader' } }, + virt_text_pos = 'overlay', + priority = 100, + }) + end +end + +---@param bufnr integer +---@return nil +function M.reapply_dirty_inline(bufnr) + if not next(_dirty_rows) then + return + end + local icons = config.get().icons + for row in pairs(_dirty_rows) do + local m = _meta[row] + if m then + vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, row - 1, row) + apply_inline_row(bufnr, row - 1, m, icons) + end + end + _dirty_rows = {} +end + +---@param bufnr integer +---@return nil +function M.attach_bytes(bufnr) + if _on_bytes_active then + return + end + _on_bytes_active = true + vim.api.nvim_buf_attach(bufnr, false, { + on_bytes = function(_, buf, _, start_row, _, _, old_end_row, _, _, new_end_row, _, _) + if buf ~= task_bufnr then + _on_bytes_active = false + return true + end + if _rendering then + return + end + local delta = new_end_row - old_end_row + if delta > 0 then + for _ = 1, delta do + table.insert(_meta, start_row + 2, { type = 'task' }) + end + elseif delta < 0 then + for _ = 1, -delta do + if _meta[start_row + 2] then + table.remove(_meta, start_row + 2) + end + end + end + for r = start_row + 1, start_row + 1 + math.max(0, new_end_row) do + _dirty_rows[r] = true + end + end, + }) +end + +---@return nil +function M.persist_folds() + log.debug( + ('persist_folds: view=%s store=%s'):format(tostring(current_view), tostring(_store ~= nil)) + ) + if current_view ~= 'category' or not _store then + log.debug('persist_folds: early return (view or store)') + return + end + local bufnr = task_bufnr + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + log.debug('persist_folds: early return (no valid bufnr)') + return + end + local folded = {} + local seen = {} + local wins = vim.fn.win_findbuf(bufnr) + log.debug( + ('persist_folds: checking %d windows for bufnr=%d, meta has %d entries'):format( + #wins, + bufnr, + #_meta + ) + ) + for _, winid in ipairs(wins) do + if vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_call(winid, function() + for lnum, m in ipairs(_meta) do + if m.type == 'header' and m.category and not seen[m.category] then + local closed = vim.fn.foldclosed(lnum) + log.debug( + ('persist_folds: win=%d lnum=%d cat=%s foldclosed=%d'):format( + winid, + lnum, + m.category, + closed + ) + ) + if closed ~= -1 then + seen[m.category] = true + table.insert(folded, m.category) + end + end + end + end) + end + end + log.debug( + ('persist_folds: saving %d folded categories: %s'):format(#folded, table.concat(folded, ', ')) + ) + _store:set_folded_categories(folded) +end + +---@return nil function M.close() - if task_winid and vim.api.nvim_win_is_valid(task_winid) then + if not task_winid or not vim.api.nvim_win_is_valid(task_winid) then + task_winid = nil + return + end + M.persist_folds() + if _store then + _store:save() + end + local wins = vim.api.nvim_list_wins() + if #wins == 1 then + vim.cmd.enew() + else vim.api.nvim_win_close(task_winid, false) end task_winid = nil @@ -55,19 +298,13 @@ local function set_buf_options(bufnr) vim.bo[bufnr].swapfile = false vim.bo[bufnr].filetype = 'pending' vim.bo[bufnr].modifiable = true + vim.bo[bufnr].omnifunc = 'v:lua.require("pending.complete").omnifunc' end ---@param winid integer local function set_win_options(winid) vim.wo[winid].conceallevel = 3 vim.wo[winid].concealcursor = 'nvic' - vim.wo[winid].wrap = false - vim.wo[winid].number = false - vim.wo[winid].relativenumber = false - vim.wo[winid].signcolumn = 'no' - vim.wo[winid].foldcolumn = '0' - vim.wo[winid].spell = false - vim.wo[winid].cursorline = true vim.wo[winid].winfixheight = true end @@ -77,7 +314,7 @@ local function setup_syntax(bufnr) vim.cmd([[ syntax clear syntax match taskId /^\/\d\+\// conceal - syntax match taskHeader /^## .*$/ contains=taskId + syntax match taskHeader /^# .*$/ contains=taskId syntax match taskCheckbox /\[!\]/ contained containedin=taskLine syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox ]]) @@ -85,6 +322,7 @@ local function setup_syntax(bufnr) end ---@param above boolean +---@return nil function M.open_line(above) local bufnr = task_bufnr if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then @@ -114,50 +352,109 @@ 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) - vim.api.nvim_buf_clear_namespace(bufnr, task_ns, 0, -1) + local cfg = config.get() + local icons = cfg.icons + local eol_segments = parse_eol_format(cfg.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) for i, m in ipairs(line_meta) do local row = i - 1 if m.type == 'task' then - local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue' - if m.show_category then - local virt_text - if m.category and m.due then - virt_text = { { m.category .. ' ', 'PendingHeader' }, { m.due, due_hl } } - elseif m.category then - virt_text = { { m.category, 'PendingHeader' } } - elseif m.due then - virt_text = { { m.due, due_hl } } - 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 } }, + 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, 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 @@ -167,60 +464,130 @@ local function setup_highlights() vim.api.nvim_set_hl(0, 'PendingOverdue', { link = 'DiagnosticError', default = true }) vim.api.nvim_set_hl(0, 'PendingDone', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true }) + vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true }) + vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true }) +end + +---@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' then + if current_view ~= 'category' or not config.resolve_folding().enabled then return end for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do - 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 + 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 end end - end - end) - _fold_state[winid] = state + end) + _fold_state[winid] = state + end end end local function restore_folds(bufnr) - if current_view ~= 'category' then + log.debug( + ('restore_folds: view=%s folding_enabled=%s'):format( + tostring(current_view), + tostring(config.resolve_folding().enabled) + ) + ) + if current_view ~= 'category' or not config.resolve_folding().enabled then return 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().default_view - vim.api.nvim_buf_set_name(bufnr, 'pending://' .. current_view) - local tasks = store.active_tasks() + 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 local lines, line_meta if current_view == 'priority' then @@ -229,25 +596,45 @@ 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' then + if current_view == 'category' and folding.enabled 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 @@ -256,7 +643,9 @@ 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 @@ -268,7 +657,9 @@ end ---@return integer bufnr function M.open() setup_highlights() - store.load() + if _store then + _store:load() + end if task_winid and vim.api.nvim_win_is_valid(task_winid) then vim.api.nvim_set_current_win(task_winid) @@ -279,6 +670,7 @@ 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 new file mode 100644 index 0000000..9ed4971 --- /dev/null +++ b/lua/pending/complete.lua @@ -0,0 +1,173 @@ +local config = require('pending.config') + +---@class pending.complete +local M = {} + +---@return string +local function date_key() + return config.get().date_syntax or 'due' +end + +---@return string +local function recur_key() + return config.get().recur_syntax or 'rec' +end + +---@return string[] +local function get_categories() + local s = require('pending.buffer').store() + if not s then + return {} + end + local seen = {} + local result = {} + for _, task in ipairs(s:active_tasks()) do + local cat = task.category + if cat and not seen[cat] then + seen[cat] = true + table.insert(result, cat) + end + end + table.sort(result) + return result +end + +---@return { word: string, info: string }[] +local function date_completions() + return { + { word = 'today', info = "Today's date" }, + { word = 'tomorrow', info = "Tomorrow's date" }, + { word = 'yesterday', info = "Yesterday's date" }, + { word = '+1d', info = '1 day from today' }, + { word = '+2d', info = '2 days from today' }, + { word = '+3d', info = '3 days from today' }, + { word = '+1w', info = '1 week from today' }, + { word = '+2w', info = '2 weeks from today' }, + { word = '+1m', info = '1 month from today' }, + { word = 'mon', info = 'Next Monday' }, + { word = 'tue', info = 'Next Tuesday' }, + { word = 'wed', info = 'Next Wednesday' }, + { word = 'thu', info = 'Next Thursday' }, + { word = 'fri', info = 'Next Friday' }, + { word = 'sat', info = 'Next Saturday' }, + { word = 'sun', info = 'Next Sunday' }, + { word = 'eod', info = 'End of day (today)' }, + { word = 'eow', info = 'End of week (Sunday)' }, + { word = 'eom', info = 'End of month' }, + { word = 'eoq', info = 'End of quarter' }, + { word = 'eoy', info = 'End of year (Dec 31)' }, + { word = 'sow', info = 'Start of week (Monday)' }, + { word = 'som', info = 'Start of month' }, + { word = 'soq', info = 'Start of quarter' }, + { word = 'soy', info = 'Start of year (Jan 1)' }, + { word = 'later', info = 'Someday (sentinel date)' }, + { word = 'today@08:00', info = 'Today at 08:00' }, + { word = 'today@09:00', info = 'Today at 09:00' }, + { word = 'today@10:00', info = 'Today at 10:00' }, + { word = 'today@12:00', info = 'Today at 12:00' }, + { word = 'today@14:00', info = 'Today at 14:00' }, + { word = 'today@17:00', info = 'Today at 17:00' }, + } +end + +---@type table +local recur_descriptions = { + daily = 'Every day', + weekdays = 'Monday through Friday', + weekly = 'Every week', + biweekly = 'Every 2 weeks', + monthly = 'Every month', + quarterly = 'Every 3 months', + yearly = 'Every year', + ['2d'] = 'Every 2 days', + ['3d'] = 'Every 3 days', + ['2w'] = 'Every 2 weeks', + ['3w'] = 'Every 3 weeks', + ['2m'] = 'Every 2 months', + ['3m'] = 'Every 3 months', + ['6m'] = 'Every 6 months', + ['2y'] = 'Every 2 years', +} + +---@return { word: string, info: string }[] +local function recur_completions() + local recur = require('pending.recur') + local list = recur.shorthand_list() + local result = {} + for _, s in ipairs(list) do + local desc = recur_descriptions[s] or s + table.insert(result, { word = s, info = desc }) + end + for _, s in ipairs(list) do + local desc = recur_descriptions[s] or s + table.insert(result, { word = '!' .. s, info = desc .. ' (from completion date)' }) + end + return result +end + +---@type string? +local _complete_source = nil + +---@param findstart integer +---@param base string +---@return integer|table[] +function M.omnifunc(findstart, base) + if findstart == 1 then + local line = vim.api.nvim_get_current_line() + local col = vim.api.nvim_win_get_cursor(0)[2] + local before = line:sub(1, col) + + local dk = date_key() + local rk = recur_key() + + local checks = { + { vim.pesc(dk) .. ':([%S]*)$', dk }, + { 'cat:([%S]*)$', 'cat' }, + { vim.pesc(rk) .. ':([%S]*)$', rk }, + } + + for _, check in ipairs(checks) do + local start = before:find(check[1]) + if start then + local colon_pos = before:find(':', start, true) + if colon_pos then + _complete_source = check[2] + return colon_pos + end + end + end + + _complete_source = nil + return -1 + end + + local matches = {} + local source = _complete_source or '' + + local dk = date_key() + local rk = recur_key() + + if source == dk then + for _, c in ipairs(date_completions()) do + if base == '' or c.word:sub(1, #base) == base then + table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info }) + end + end + elseif source == 'cat' then + for _, c in ipairs(get_categories()) do + if base == '' or c:sub(1, #base) == base then + table.insert(matches, { word = c, menu = '[cat]' }) + end + end + elseif source == rk then + for _, c in ipairs(recur_completions()) do + if base == '' or c.word:sub(1, #base) == base then + table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info }) + end + end + end + + return matches +end + +return M diff --git a/lua/pending/config.lua b/lua/pending/config.lua index b61f44a..36c63d2 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -1,16 +1,80 @@ +---@class pending.FoldingConfig +---@field foldtext? string|false + +---@class pending.ResolvedFolding +---@field enabled boolean +---@field foldtext string|false + +---@class pending.Icons +---@field pending string +---@field done string +---@field priority string +---@field due string +---@field recur string +---@field category string + ---@class pending.GcalConfig ----@field calendar? string +---@field remote_delete? boolean ---@field credentials_path? string +---@field client_id? string +---@field client_secret? string + +---@class pending.GtasksConfig +---@field remote_delete? boolean +---@field credentials_path? string +---@field client_id? string +---@field client_secret? string + +---@class pending.SyncConfig +---@field remote_delete? boolean +---@field gcal? pending.GcalConfig +---@field gtasks? pending.GtasksConfig + +---@class pending.Keymaps +---@field close? string|false +---@field toggle? string|false +---@field view? string|false +---@field priority? string|false +---@field date? string|false +---@field undo? string|false +---@field filter? string|false +---@field open_line? string|false +---@field open_line_above? string|false +---@field a_task? string|false +---@field i_task? string|false +---@field a_category? string|false +---@field i_category? string|false +---@field next_header? string|false +---@field prev_header? string|false +---@field next_task? string|false +---@field prev_task? string|false + +---@class pending.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 category_order? string[] +---@field recur_syntax string +---@field someday_date string +---@field input_date_formats? string[] ---@field drawer_height? integer ----@field gcal? pending.GcalConfig +---@field debug? boolean +---@field keymaps pending.Keymaps +---@field view pending.ViewConfig +---@field sync? pending.SyncConfig +---@field icons pending.Icons ---@class pending.config local M = {} @@ -18,11 +82,48 @@ 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', - category_order = {}, + recur_syntax = 'rec', + someday_date = '9999-12-30', + view = { + default = 'category', + eol_format = '%c %r %d', + category = { + order = {}, + folding = true, + }, + queue = {}, + }, + keymaps = { + close = 'q', + toggle = '', + view = '', + priority = '!', + date = 'D', + undo = 'U', + filter = 'F', + open_line = 'o', + open_line_above = 'O', + a_task = 'at', + i_task = 'it', + a_category = 'aC', + i_category = 'iC', + next_header = ']]', + prev_header = '[[', + next_task = ']t', + prev_task = '[t', + }, + sync = {}, + icons = { + pending = ' ', + done = 'x', + priority = '!', + due = '.', + recur = '~', + category = '#', + }, } ---@type pending.Config? @@ -38,8 +139,20 @@ 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 85f083c..6b79b8a 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -1,6 +1,5 @@ local config = require('pending.config') local parse = require('pending.parse') -local store = require('pending.store') ---@class pending.ParsedEntry ---@field type 'task'|'header'|'blank' @@ -10,6 +9,8 @@ local store = require('pending.store') ---@field status? string ---@field category? string ---@field due? string +---@field rec? string +---@field rec_mode? string ---@field lnum integer ---@class pending.diff @@ -25,16 +26,21 @@ end function M.parse_buffer(lines) local result = {} local current_category = nil + local start = 1 + if lines[1] and lines[1]:match('^FILTER:') then + start = 2 + end - for i, line in ipairs(lines) do - local id, body = line:match('^/(%d+)/(- %[.%] .*)$') + for i = start, #lines do + local line = lines[i] + 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 = state_char == 'x' and 'done' or 'pending' @@ -48,11 +54,13 @@ function M.parse_buffer(lines) status = status, category = metadata.cat or current_category or config.get().default_category, due = metadata.due, + rec = metadata.rec, + rec_mode = metadata.rec_mode, lnum = i, }) end - elseif line:match('^## (.+)$') then - current_category = line:match('^## (.+)$') + elseif line:match('^# (.+)$') then + current_category = line:match('^# (.+)$') table.insert(result, { type = 'header', category = current_category, lnum = i }) end end @@ -61,10 +69,13 @@ function M.parse_buffer(lines) end ---@param lines string[] -function M.apply(lines) +---@param s pending.Store +---@param hidden_ids? table +---@return nil +function M.apply(lines, s, hidden_ids) local parsed = M.parse_buffer(lines) local now = timestamp() - local data = store.data() + local data = s:data() local old_by_id = {} for _, task in ipairs(data.tasks) do @@ -85,11 +96,13 @@ function M.apply(lines) if entry.id and old_by_id[entry.id] then if seen_ids[entry.id] then - store.add({ + s:add({ description = entry.description, category = entry.category, priority = entry.priority, due = entry.due, + recur = entry.rec, + recur_mode = entry.rec_mode, order = order_counter, }) else @@ -108,10 +121,20 @@ function M.apply(lines) task.priority = entry.priority changed = true end - if task.due ~= entry.due then + if entry.due ~= nil and task.due ~= entry.due then task.due = entry.due changed = true end + if entry.rec ~= nil then + if task.recur ~= entry.rec then + task.recur = entry.rec + changed = true + end + if task.recur_mode ~= entry.rec_mode then + task.recur_mode = entry.rec_mode + changed = true + end + end if entry.status and task.status ~= entry.status then task.status = entry.status if entry.status == 'done' then @@ -130,11 +153,13 @@ function M.apply(lines) end end else - store.add({ + s:add({ description = entry.description, category = entry.category, priority = entry.priority, due = entry.due, + recur = entry.rec, + recur_mode = entry.rec_mode, order = order_counter, }) end @@ -143,14 +168,14 @@ function M.apply(lines) end for id, task in pairs(old_by_id) do - if not seen_ids[id] then + if not seen_ids[id] and not (hidden_ids and hidden_ids[id]) then task.status = 'deleted' task['end'] = now task.modified = now end end - store.save() + s:save() end return M diff --git a/lua/pending/health.lua b/lua/pending/health.lua index 8a12da4..f819269 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -1,5 +1,6 @@ local M = {} +---@return nil function M.check() vim.health.start('pending.nvim') @@ -9,42 +10,54 @@ function M.check() return end - local cfg = config.get() + config.get() vim.health.ok('Config loaded') - vim.health.info('Data path: ' .. cfg.data_path) - local data_dir = vim.fn.fnamemodify(cfg.data_path, ':h') - if vim.fn.isdirectory(data_dir) == 1 then - vim.health.ok('Data directory exists: ' .. data_dir) - else - vim.health.warn('Data directory does not exist yet: ' .. data_dir) + local store_ok, store = pcall(require, 'pending.store') + if not store_ok then + vim.health.error('Failed to load pending.store') + return end - if vim.fn.filereadable(cfg.data_path) == 1 then - local store_ok, store = pcall(require, 'pending.store') - if store_ok then - local load_ok, err = pcall(store.load) - if load_ok then - local tasks = store.tasks() - vim.health.ok('Data file loaded: ' .. #tasks .. ' tasks') - else - vim.health.error('Failed to load data file: ' .. tostring(err)) + local resolved_path = store.resolve_path() + vim.health.info('Store path: ' .. resolved_path) + + if vim.fn.filereadable(resolved_path) == 1 then + local s = store.new(resolved_path) + local load_ok, err = pcall(function() + s:load() + end) + if load_ok then + local tasks = s:tasks() + vim.health.ok('Data file loaded: ' .. #tasks .. ' tasks') + local recur = require('pending.recur') + local invalid_count = 0 + for _, task in ipairs(tasks) do + if task.recur and not recur.validate(task.recur) then + invalid_count = invalid_count + 1 + vim.health.warn('Task ' .. task.id .. ' has invalid recurrence spec: ' .. task.recur) + end + end + if invalid_count == 0 then + vim.health.ok('All recurrence specs are valid') + end + else + vim.health.error('Failed to load data file: ' .. tostring(err)) + end + end + + local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true) + if #sync_paths == 0 then + vim.health.info('No sync backends found') + else + for _, path in ipairs(sync_paths) do + local name = vim.fn.fnamemodify(path, ':t:r') + local bok, backend = pcall(require, 'pending.sync.' .. name) + if bok and backend.name and type(backend.health) == 'function' then + vim.health.start('pending.nvim: sync/' .. name) + backend.health() end end - else - vim.health.info('No data file yet (will be created on first save)') - end - - if vim.fn.executable('curl') == 1 then - vim.health.ok('curl found (required for Google Calendar sync)') - else - vim.health.warn('curl not found (needed for Google Calendar sync)') - end - - if vim.fn.executable('openssl') == 1 then - vim.health.ok('openssl found (required for OAuth PKCE)') - else - vim.health.warn('openssl not found (needed for Google Calendar OAuth)') end end diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 14b9c24..4d05503 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -1,24 +1,216 @@ 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 == 'pending' then + pending = pending + 1 + if task.priority > 0 then + priority = priority + 1 + end + if task.due and task.due ~= someday then + if parse.is_overdue(task.due) then + overdue = overdue + 1 + elseif parse.is_today(task.due) then + today = today + 1 + end + local date_part = task.due:match('^(%d%d%d%d%-%d%d%-%d%d)') or task.due + if date_part >= today_str and (not next_due or task.due < next_due) then + next_due = task.due + end + end + end + end + + _counts = { + overdue = overdue, + today = today, + pending = pending, + priority = priority, + next_due = next_due, + } + + vim.api.nvim_exec_autocmds('User', { pattern = 'PendingStatusChanged' }) +end + +---@return nil +local function _save_and_notify() + get_store():save() + M._recompute_counts() +end + +---@return 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 + 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', { @@ -32,12 +224,64 @@ 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 - store.load() + get_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) @@ -46,71 +290,197 @@ 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 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) + 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, + 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 end ---@param bufnr integer +---@return nil function M._on_write(bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local snapshot = store.snapshot() - table.insert(_undo_states, snapshot) - if #_undo_states > UNDO_MAX then - table.remove(_undo_states, 1) + local predicates = buffer.filter_predicates() + if lines[1] and lines[1]:match('^FILTER:') then + local pred_str = lines[1]:match('^FILTER:%s*(.*)$') or '' + predicates = {} + for word in pred_str:gmatch('%S+') do + table.insert(predicates, word) + end + lines = vim.list_slice(lines, 2) + elseif #buffer.filter_predicates() > 0 then + predicates = {} end - diff.apply(lines) + local s = get_store() + local tasks = s:active_tasks() + local hidden = compute_hidden_ids(tasks, predicates) + buffer.set_filter(predicates, hidden) + local snapshot = s:snapshot() + local stack = s:undo_stack() + table.insert(stack, snapshot) + if #stack > UNDO_MAX then + table.remove(stack, 1) + end + diff.apply(lines, s, hidden) + M._recompute_counts() buffer.render(bufnr) end +---@return nil function M.undo_write() - if #_undo_states == 0 then - vim.notify('Nothing to undo.', vim.log.levels.WARN) + if not require_saved() then return end - local state = table.remove(_undo_states) - store.replace_tasks(state) - store.save() + 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() 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 @@ -120,16 +490,30 @@ function M.toggle_complete() if not id then return end - local task = store.get(id) + local s = get_store() + local task = s:get(id) if not task then return end if task.status == 'done' then - store.update(id, { status = 'pending', ['end'] = vim.NIL }) + s:update(id, { status = 'pending', ['end'] = vim.NIL }) else - store.update(id, { status = 'done' }) + if task.recur and task.due then + local recur = require('pending.recur') + local mode = task.recur_mode or 'scheduled' + local next_date = recur.next_due(task.due, task.recur, mode) + s:add({ + description = task.description, + category = task.category, + priority = task.priority, + due = next_date, + recur = task.recur, + recur_mode = task.recur_mode, + }) + end + s:update(id, { status = 'done' }) end - store.save() + _save_and_notify() buffer.render(bufnr) for lnum, m in ipairs(buffer.meta()) do if m.id == id then @@ -139,11 +523,74 @@ 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 @@ -153,13 +600,14 @@ function M.toggle_priority() if not id then return end - local task = store.get(id) + local s = get_store() + local task = s:get(id) if not task then return end local new_priority = task.priority > 0 and 0 or 1 - store.update(id, { priority = new_priority }) - store.save() + s:update(id, { priority = new_priority }) + _save_and_notify() buffer.render(bufnr) for lnum, m in ipairs(buffer.meta()) do if m.id == id then @@ -169,11 +617,15 @@ function M.toggle_priority() end 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 @@ -183,7 +635,7 @@ function M.prompt_date() if not id then return end - vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input) + vim.ui.input({ prompt = 'Due date (today, +3d, fri@2pm, etc.): ' }, function(input) if not input then return end @@ -192,90 +644,118 @@ function M.prompt_date() local resolved = parse.resolve_date(due) if resolved then due = resolved - elseif not due:match('^%d%d%d%d%-%d%d%-%d%d$') then - vim.notify('Invalid date format. Use YYYY-MM-DD.', vim.log.levels.ERROR) + elseif + not due:match('^%d%d%d%d%-%d%d%-%d%d$') + and not due:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$') + then + log.error('Invalid date format. Use YYYY-MM-DD or YYYY-MM-DDThh:mm.') return end end - store.update(id, { due = due }) - store.save() + get_store():update(id, { due = due }) + _save_and_notify() buffer.render(bufnr) end) end ---@param text string +---@return nil function M.add(text) if not text or text == '' then - vim.notify('Usage: :Pending add ', vim.log.levels.ERROR) + log.error('Usage: :Pending add ') return end - store.load() + local s = get_store() + s:load() local description, metadata = parse.command_add(text) if not description or description == '' then - vim.notify('Pending must have a description.', vim.log.levels.ERROR) + log.error('Task must have a description.') return end - store.add({ + s:add({ description = description, category = metadata.cat, due = metadata.due, + recur = metadata.rec, + recur_mode = metadata.rec_mode, }) - store.save() + _save_and_notify() local bufnr = buffer.bufnr() if bufnr and vim.api.nvim_buf_is_valid(bufnr) then buffer.render(bufnr) end - vim.notify('Pending added: ' .. description) + log.info('Task added: ' .. description) end -function M.sync() - local ok, gcal = pcall(require, 'pending.sync.gcal') +---@type string[] +local SYNC_BACKENDS = { 'gcal', 'gtasks' } + +---@type table +local SYNC_BACKEND_SET = {} +for _, b in ipairs(SYNC_BACKENDS) do + SYNC_BACKEND_SET[b] = true +end + +---@param backend_name string +---@param action? string +---@return nil +local function run_sync(backend_name, action) + local ok, backend = pcall(require, 'pending.sync.' .. backend_name) if not ok then - vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR) + log.error('Unknown sync backend: ' .. backend_name) return end - gcal.sync() + if not action or action == '' then + local actions = {} + for k, v in pairs(backend) do + if type(v) == 'function' and k:sub(1, 1) ~= '_' and k ~= 'health' 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]() end ---@param days? integer +---@return nil function M.archive(days) - days = days or 30 - local cutoff = os.time() - (days * 86400) - local tasks = store.tasks() + if days == nil then + 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 archived = 0 local kept = {} for _, task in ipairs(tasks) do if (task.status == 'done' or task.status == 'deleted') and task['end'] then - local y, mo, d, h, mi, s = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$') - 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 + if task['end'] < cutoff then + archived = archived + 1 + goto skip end end table.insert(kept, task) ::skip:: end - store.replace_tasks(kept) - store.save() - vim.notify('Archived ' .. archived .. ' tasks.') + s:replace_tasks(kept) + _save_and_notify() + log.info('Archived ' .. archived .. ' task' .. (archived == 1 and '' or 's') .. '.') 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 @@ -283,9 +763,14 @@ function M.due() if meta and bufnr then for lnum, m in ipairs(meta) do - if m.type == 'task' and m.raw_due and m.status ~= 'done' and m.raw_due <= today then - local task = store.get(m.id or 0) - local label = m.raw_due < today and '[OVERDUE] ' or '[DUE] ' + if + m.type == 'task' + and m.raw_due + and m.status ~= 'done' + and (parse.is_overdue(m.raw_due) or parse.is_today(m.raw_due)) + then + local task = get_store():get(m.id or 0) + local label = parse.is_overdue(m.raw_due) and '[OVERDUE] ' or '[DUE] ' table.insert(qf_items, { bufnr = bufnr, lnum = lnum, @@ -295,10 +780,15 @@ function M.due() end end else - store.load() - for _, task in ipairs(store.active_tasks()) do - if task.status == 'pending' and task.due and task.due <= today then - local label = task.due < today and '[OVERDUE] ' or '[DUE] ' + local s = get_store() + s:load() + for _, task in ipairs(s:active_tasks()) do + if + task.status == 'pending' + and task.due + and (parse.is_overdue(task.due) or parse.is_today(task.due)) + then + local label = parse.is_overdue(task.due) and '[OVERDUE] ' or '[DUE] ' local text = label .. task.description if task.category then text = text .. ' [' .. task.category .. ']' @@ -309,7 +799,7 @@ function M.due() end if #qf_items == 0 then - vim.notify('No due or overdue tasks.') + log.info('No due or overdue tasks.') return end @@ -317,68 +807,208 @@ function M.due() vim.cmd('copen') end -function M.show_help() +---@param token string +---@return string|nil field +---@return any value +---@return string|nil err +local function parse_edit_token(token) + local recur = require('pending.recur') local cfg = require('pending.config').get() local dk = cfg.date_syntax or 'due' - local lines = { - 'pending.nvim keybindings', - '', - ' Toggle complete/uncomplete', - ' Switch category/priority view', - '! Toggle urgent', - 'D Set due date', - 'U Undo last write', - 'o / O Add new task line', - 'dd Delete task line (on :w)', - 'p / P Paste (duplicates get new IDs)', - 'zc / zo Fold/unfold category (category view)', - ':w Save all changes', - '', - ':Pending add Quick-add task', - ':Pending add Cat: Quick-add with category', - ':Pending due Show overdue/due qflist', - ':Pending sync Push to Google Calendar', - ':Pending archive [days] Purge old done tasks', - ':Pending undo Undo last write', - '', - 'Inline metadata (on new lines before :w):', - ' ' .. dk .. ':YYYY-MM-DD Set due date', - ' cat:Name Set category', - '', - 'Due date input:', - ' today, tomorrow, +Nd, mon-sun', - ' Empty input clears due date', - '', - 'Highlights:', - ' PendingOverdue overdue tasks (red)', - ' PendingPriority [!] urgent tasks', - '', - 'Press q or to close', - } - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - vim.bo[buf].modifiable = false - vim.bo[buf].bufhidden = 'wipe' - local width = 54 - local height = #lines - local win = vim.api.nvim_open_win(buf, true, { - relative = 'editor', - width = width, - height = height, - col = math.floor((vim.o.columns - width) / 2), - row = math.floor((vim.o.lines - height) / 2), - style = 'minimal', - border = 'rounded', - }) - vim.keymap.set('n', 'q', function() - vim.api.nvim_win_close(win, true) - end, { buffer = buf, silent = true }) - vim.keymap.set('n', '', function() - vim.api.nvim_win_close(win, true) - end, { buffer = buf, silent = true }) + local rk = cfg.recur_syntax or 'rec' + + if token == '+!' then + return 'priority', 1, nil + end + if token == '-!' then + return 'priority', 0, nil + end + if token == '-due' or token == '-' .. dk then + return 'due', vim.NIL, nil + end + if token == '-cat' then + return 'category', vim.NIL, nil + end + if token == '-rec' or token == '-' .. rk then + return 'recur', vim.NIL, nil + end + local due_val = token:match('^' .. vim.pesc(dk) .. ':(.+)$') + if due_val then + local resolved = parse.resolve_date(due_val) + if resolved then + return 'due', resolved, nil + end + if + due_val:match('^%d%d%d%d%-%d%d%-%d%d$') or due_val:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$') + then + return 'due', due_val, nil + end + return nil, + nil, + 'Invalid date: ' .. due_val .. '. Use YYYY-MM-DD, today, tomorrow, +Nd, weekday names, etc.' + end + + local cat_val = token:match('^cat:(.+)$') + if cat_val then + return 'category', cat_val, nil + end + + local rec_val = token:match('^' .. vim.pesc(rk) .. ':(.+)$') + if rec_val then + local raw_spec = rec_val + local rec_mode = nil + if raw_spec:sub(1, 1) == '!' then + rec_mode = 'completion' + raw_spec = raw_spec:sub(2) + end + if not recur.validate(raw_spec) then + return nil, nil, 'Invalid recurrence pattern: ' .. rec_val + end + return 'recur', { spec = raw_spec, mode = rec_mode }, nil + end + + return nil, + nil, + 'Unknown operation: ' + .. token + .. '. Valid: ' + .. dk + .. ':, cat:, ' + .. rk + .. ':, +!, -!, -' + .. dk + .. ', -cat, -' + .. rk +end + +---@param id_str string +---@param rest string +---@return nil +function M.edit(id_str, rest) + if not id_str or id_str == '' then + log.error( + 'Usage: :Pending edit [due:] [cat:] [rec:] [+!] [-!] [-due] [-cat] [-rec]' + ) + return + end + + local id = tonumber(id_str) + if not id then + log.error('Invalid task ID: ' .. id_str) + return + 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 + table.insert(feedback, value == 1 and 'priority added' or 'priority removed') + end + end + + local snapshot = s:snapshot() + local stack = s:undo_stack() + table.insert(stack, snapshot) + if #stack > UNDO_MAX then + table.remove(stack, 1) + end + + s:update(id, updates) + + _save_and_notify() + + local bufnr = buffer.bufnr() + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + buffer.render(bufnr) + end + + log.info('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', ') .. '.') +end + +---@param args? string +---@return nil +function M.auth(args) + local oauth = require('pending.sync.oauth') + local parts = {} + for w in (args or ''):gmatch('%S+') do + table.insert(parts, w) + end + local action = parts[#parts] + if action == parts[1] and (action == 'gtasks' or action == 'gcal') then + action = nil + end + + if action == 'clear' then + oauth.google_client:clear_tokens() + log.info('OAuth tokens cleared — run :Pending auth to re-authenticate.') + elseif action == 'reset' then + oauth.google_client:_wipe() + log.info('OAuth tokens and credentials cleared — run :Pending auth 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 ---@param args string +---@return nil function M.command(args) if not args or args == '' then M.open() @@ -387,18 +1017,37 @@ function M.command(args) local cmd, rest = args:match('^(%S+)%s*(.*)') if cmd == 'add' then M.add(rest) - elseif cmd == 'sync' then - M.sync() + elseif cmd == '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 SYNC_BACKEND_SET[cmd] then + local action = rest:match('^(%S+)') + run_sync(cmd, action) elseif cmd == 'archive' then - local d = rest ~= '' and tonumber(rest) or nil - M.archive(d) + M.archive(tonumber(rest)) elseif cmd == 'due' then M.due() + elseif cmd == 'filter' then + M.filter(rest) elseif cmd == 'undo' then M.undo_write() else - vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR) + log.error('Unknown subcommand: ' .. cmd) end end +---@return string[] +function M.sync_backends() + return SYNC_BACKENDS +end + +---@return table +function M.sync_backend_set() + return SYNC_BACKEND_SET +end + return M diff --git a/lua/pending/log.lua b/lua/pending/log.lua new file mode 100644 index 0000000..1f37c4e --- /dev/null +++ b/lua/pending/log.lua @@ -0,0 +1,30 @@ +---@class pending.log +local M = {} + +local PREFIX = '[pending.nvim]: ' + +---@param msg string +function M.info(msg) + vim.notify(PREFIX .. msg) +end + +---@param msg string +function M.warn(msg) + vim.notify(PREFIX .. msg, vim.log.levels.WARN) +end + +---@param msg string +function M.error(msg) + vim.notify(PREFIX .. msg, vim.log.levels.ERROR) +end + +---@param msg string +---@param override? boolean +function M.debug(msg, override) + local cfg = require('pending.config').get() + if cfg.debug or override then + vim.notify(PREFIX .. msg, vim.log.levels.DEBUG) + end +end + +return M diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index ebe909a..3e90b65 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -24,11 +24,92 @@ local function is_valid_date(s) return check.year == yn and check.month == mn and check.day == dn end +---@param s string +---@return boolean +local function is_valid_time(s) + local h, m = s:match('^(%d%d):(%d%d)$') + if not h then + return false + end + local hn = tonumber(h) --[[@as integer]] + local mn = tonumber(m) --[[@as integer]] + return hn >= 0 and hn <= 23 and mn >= 0 and mn <= 59 +end + +---@param s string +---@return string|nil +local function normalize_time(s) + local h, m, period + + h, m, period = s:match('^(%d+):(%d%d)([ap]m)$') + if not h then + h, period = s:match('^(%d+)([ap]m)$') + if h then + m = '00' + end + end + if not h then + h, m = s:match('^(%d%d):(%d%d)$') + end + if not h then + h, m = s:match('^(%d):(%d%d)$') + end + if not h then + h = s:match('^(%d+)$') + if h then + m = '00' + end + end + + if not h then + return nil + end + + local hn = tonumber(h) --[[@as integer]] + local mn = tonumber(m) --[[@as integer]] + + if period then + if hn < 1 or hn > 12 then + return nil + end + if period == 'am' then + hn = hn == 12 and 0 or hn + else + hn = hn == 12 and 12 or hn + 12 + end + else + if hn < 0 or hn > 23 then + return nil + end + end + + if mn < 0 or mn > 59 then + return nil + end + + return string.format('%02d:%02d', hn, mn) +end + +---@param s string +---@return boolean +local function is_valid_datetime(s) + local date_part, time_part = s:match('^(.+)T(.+)$') + if not date_part then + return is_valid_date(s) + end + return is_valid_date(date_part) and is_valid_time(time_part) +end + ---@return string local function date_key() return config.get().date_syntax or 'due' end +---@return string +local function recur_key() + return config.get().recur_syntax or 'rec' +end + local weekday_map = { sun = 1, mon = 2, @@ -39,53 +120,402 @@ 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 lower = text:lower() + local date_input, time_suffix = text:match('^(.+)@(.+)$') + if time_suffix then + time_suffix = normalize_time(time_suffix) + if not time_suffix then + return nil + end + else + date_input = text + end + + local dt = date_input:match('^(%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d)$') + if dt then + local dp, tp = dt:match('^(.+)T(.+)$') + if is_valid_date(dp) and is_valid_time(tp) then + return dt + end + return nil + end + + if is_valid_date(date_input) then + return append_time(date_input, time_suffix) + end + + local lower = date_input:lower() local today = os.date('*t') --[[@as osdate]] - if lower == 'today' then - return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]] + if lower == 'today' or lower == 'eod' then + return append_time(today_str(today), time_suffix) + end + + if lower == 'yesterday' then + return append_time( + os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day - 1 })) --[[@as string]], + time_suffix + ) end if lower == 'tomorrow' then - return os.date( - '%Y-%m-%d', - os.time({ year = today.year, month = today.month, day = today.day + 1 }) - ) --[[@as string]] + return append_time( + os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) --[[@as string]], + time_suffix + ) + end + + if lower == 'sow' then + local delta = -((today.wday - 2) % 7) + return append_time( + os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + delta }) + ) --[[@as string]], + time_suffix + ) + end + + if lower == 'eow' then + local delta = (1 - today.wday) % 7 + return append_time( + os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + delta }) + ) --[[@as string]], + time_suffix + ) + end + + if lower == 'som' then + return append_time( + os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = 1 })) --[[@as string]], + time_suffix + ) + end + + if lower == 'eom' then + return append_time( + os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 })) --[[@as string]], + time_suffix + ) + end + + if lower == 'soq' then + local q = math.ceil(today.month / 3) + local first_month = (q - 1) * 3 + 1 + return append_time( + os.date('%Y-%m-%d', os.time({ year = today.year, month = first_month, day = 1 })) --[[@as string]], + time_suffix + ) + end + + if lower == 'eoq' then + local q = math.ceil(today.month / 3) + local last_month = q * 3 + return append_time( + os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 })) --[[@as string]], + time_suffix + ) + end + + if lower == 'soy' then + return append_time( + os.date('%Y-%m-%d', os.time({ year = today.year, month = 1, day = 1 })) --[[@as string]], + time_suffix + ) + end + + if lower == 'eoy' then + return append_time( + os.date('%Y-%m-%d', os.time({ year = today.year, month = 12, day = 31 })) --[[@as string]], + time_suffix + ) + end + + if lower == 'later' or lower == 'someday' then + return append_time(config.get().someday_date, time_suffix) end local n = lower:match('^%+(%d+)d$') if n then - return os.date( - '%Y-%m-%d', - os.time({ - year = today.year, - month = today.month, - day = today.day + ( - tonumber(n) --[[@as integer]] - ), - }) - ) --[[@as string]] + return append_time( + os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month, + day = today.day + ( + tonumber(n) --[[@as integer]] + ), + }) + ) --[[@as string]], + time_suffix + ) + end + + n = lower:match('^%+(%d+)w$') + if n then + return append_time( + os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month, + day = today.day + ( + tonumber(n) --[[@as integer]] + ) * 7, + }) + ) --[[@as string]], + time_suffix + ) + end + + n = lower:match('^%+(%d+)m$') + if n then + return append_time( + os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month + ( + tonumber(n) --[[@as integer]] + ), + day = today.day, + }) + ) --[[@as string]], + time_suffix + ) + end + + n = lower:match('^%-(%d+)d$') + if n then + return append_time( + os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month, + day = today.day - ( + tonumber(n) --[[@as integer]] + ), + }) + ) --[[@as string]], + time_suffix + ) + end + + n = lower:match('^%-(%d+)w$') + if n then + return append_time( + os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month, + day = today.day - ( + tonumber(n) --[[@as integer]] + ) * 7, + }) + ) --[[@as string]], + time_suffix + ) + end + + local ord = lower:match('^(%d+)[snrt][tdh]$') + if ord then + local day_num = tonumber(ord) --[[@as integer]] + if day_num >= 1 and day_num <= 31 then + local m, y = today.month, today.year + if today.day >= day_num then + m = m + 1 + if m > 12 then + m = 1 + y = y + 1 + end + end + local t = os.time({ year = y, month = m, day = day_num }) + local check = os.date('*t', t) --[[@as osdate]] + if check.day == day_num then + return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix) + end + m = m + 1 + if m > 12 then + m = 1 + y = y + 1 + end + t = os.time({ year = y, month = m, day = day_num }) + check = os.date('*t', t) --[[@as osdate]] + if check.day == day_num then + return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix) + end + return nil + end + end + + local target_month = month_map[lower] + if target_month then + local y = today.year + if today.month >= target_month then + y = y + 1 + end + return append_time( + os.date('%Y-%m-%d', os.time({ year = y, month = target_month, day = 1 })) --[[@as string]], + time_suffix + ) end local target_wday = weekday_map[lower] if target_wday then local current_wday = today.wday local delta = (target_wday - current_wday) % 7 - return os.date( - '%Y-%m-%d', - os.time({ year = today.year, month = today.month, day = today.day + delta }) - ) --[[@as string]] + return append_time( + os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + delta }) + ) --[[@as string]], + time_suffix + ) end - return nil + return try_input_date_formats(date_input, time_suffix) end ---@param text string ---@return string description ----@return { due?: string, cat?: string } metadata +---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } metadata function M.body(text) local tokens = {} for token in text:gmatch('%S+') do @@ -95,8 +525,10 @@ function M.body(text) local metadata = {} local i = #tokens local dk = date_key() - local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$' + local rk = recur_key() + local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d[T%d:]*)$' local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$' + local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$' while i >= 1 do local token = tokens[i] @@ -105,7 +537,7 @@ function M.body(text) if metadata.due then break end - if not is_valid_date(due_val) then + if not is_valid_datetime(due_val) then break end metadata.due = due_val @@ -131,7 +563,25 @@ function M.body(text) metadata.cat = cat_val i = i - 1 else - break + local rec_val = token:match(rec_pattern) + if rec_val then + if metadata.rec then + break + end + local recur = require('pending.recur') + local raw_spec = rec_val + if raw_spec:sub(1, 1) == '!' then + metadata.rec_mode = 'completion' + raw_spec = raw_spec:sub(2) + end + if not recur.validate(raw_spec) then + break + end + metadata.rec = raw_spec + i = i - 1 + else + break + end end end end @@ -148,7 +598,7 @@ end ---@param text string ---@return string description ----@return { due?: string, cat?: string } metadata +---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } metadata function M.command_add(text) local cat_prefix = text:match('^(%S.-):%s') if cat_prefix then @@ -165,4 +615,39 @@ function M.command_add(text) return M.body(text) end +---@param due string +---@return boolean +function M.is_overdue(due) + local now = os.date('*t') --[[@as osdate]] + local today = os.date('%Y-%m-%d') --[[@as string]] + local date_part, time_part = due:match('^(.+)T(.+)$') + if not date_part then + return due < today + end + if date_part < today then + return true + end + if date_part > today then + return false + end + local current_time = string.format('%02d:%02d', now.hour, now.min) + return time_part < current_time +end + +---@param due string +---@return boolean +function M.is_today(due) + local now = os.date('*t') --[[@as osdate]] + local today = os.date('%Y-%m-%d') --[[@as string]] + local date_part, time_part = due:match('^(.+)T(.+)$') + if not date_part then + return due == today + end + if date_part ~= today then + return false + end + local current_time = string.format('%02d:%02d', now.hour, now.min) + return time_part >= current_time +end + return M diff --git a/lua/pending/recur.lua b/lua/pending/recur.lua new file mode 100644 index 0000000..9c647aa --- /dev/null +++ b/lua/pending/recur.lua @@ -0,0 +1,188 @@ +---@class pending.RecurSpec +---@field freq 'daily'|'weekly'|'monthly'|'yearly' +---@field interval integer +---@field byday? string[] +---@field from_completion boolean +---@field _raw? string + +---@class pending.recur +local M = {} + +---@type table +local named = { + daily = { freq = 'daily', interval = 1, from_completion = false }, + weekdays = { + freq = 'weekly', + interval = 1, + byday = { 'MO', 'TU', 'WE', 'TH', 'FR' }, + from_completion = false, + }, + weekly = { freq = 'weekly', interval = 1, from_completion = false }, + biweekly = { freq = 'weekly', interval = 2, from_completion = false }, + monthly = { freq = 'monthly', interval = 1, from_completion = false }, + quarterly = { freq = 'monthly', interval = 3, from_completion = false }, + yearly = { freq = 'yearly', interval = 1, from_completion = false }, + annual = { freq = 'yearly', interval = 1, from_completion = false }, +} + +---@param spec string +---@return pending.RecurSpec? +function M.parse(spec) + local from_completion = false + local s = spec + + if s:sub(1, 1) == '!' then + from_completion = true + s = s:sub(2) + end + + local lower = s:lower() + + local base = named[lower] + if base then + return { + freq = base.freq, + interval = base.interval, + byday = base.byday, + from_completion = from_completion, + } + end + + local n, unit = lower:match('^(%d+)([dwmy])$') + if n then + local num = tonumber(n) --[[@as integer]] + if num < 1 then + return nil + end + local freq_map = { d = 'daily', w = 'weekly', m = 'monthly', y = 'yearly' } + return { + freq = freq_map[unit], + interval = num, + from_completion = from_completion, + } + end + + if s:match('^FREQ=') then + return { + freq = 'daily', + interval = 1, + from_completion = from_completion, + _raw = s, + } + end + + return nil +end + +---@param spec string +---@return boolean +function M.validate(spec) + return M.parse(spec) ~= nil +end + +---@param due string +---@return string date_part +---@return string? time_part +local function split_datetime(due) + local dp, tp = due:match('^(.+)T(.+)$') + if dp then + return dp, tp + end + return due, nil +end + +---@param base_date string +---@param freq string +---@param interval integer +---@return string +local function advance_date(base_date, freq, interval) + local date_part, time_part = split_datetime(base_date) + local y, m, d = date_part:match('^(%d+)-(%d+)-(%d+)$') + local yn = tonumber(y) --[[@as integer]] + local mn = tonumber(m) --[[@as integer]] + local dn = tonumber(d) --[[@as integer]] + + local result + if freq == 'daily' then + result = os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval })) --[[@as string]] + elseif freq == 'weekly' then + result = os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval * 7 })) --[[@as string]] + elseif freq == 'monthly' then + local new_m = mn + interval + local new_y = yn + while new_m > 12 do + new_m = new_m - 12 + new_y = new_y + 1 + end + local last_day = os.date('*t', os.time({ year = new_y, month = new_m + 1, day = 0 })) --[[@as osdate]] + local clamped_d = math.min(dn, last_day.day --[[@as integer]]) + result = os.date('%Y-%m-%d', os.time({ year = new_y, month = new_m, day = clamped_d })) --[[@as string]] + elseif freq == 'yearly' then + local new_y = yn + interval + local last_day = os.date('*t', os.time({ year = new_y, month = mn + 1, day = 0 })) --[[@as osdate]] + local clamped_d = math.min(dn, last_day.day --[[@as integer]]) + result = os.date('%Y-%m-%d', os.time({ year = new_y, month = mn, day = clamped_d })) --[[@as string]] + else + return base_date + end + + if time_part then + return result .. 'T' .. time_part + end + return result +end + +---@param base_date string +---@param spec string +---@param mode 'scheduled'|'completion' +---@return string +function M.next_due(base_date, spec, mode) + local parsed = M.parse(spec) + if not parsed then + return base_date + end + + local today = os.date('%Y-%m-%d') --[[@as string]] + local _, time_part = split_datetime(base_date) + + if mode == 'completion' then + local base = time_part and (today .. 'T' .. time_part) or today + return advance_date(base, parsed.freq, parsed.interval) + end + + local next_date = advance_date(base_date, parsed.freq, parsed.interval) + local compare_today = time_part and (today .. 'T' .. time_part) or today + while next_date <= compare_today do + next_date = advance_date(next_date, parsed.freq, parsed.interval) + end + return next_date +end + +---@param spec string +---@return string +function M.to_rrule(spec) + local parsed = M.parse(spec) + if not parsed then + return '' + end + + if parsed._raw then + return 'RRULE:' .. parsed._raw + end + + local parts = { 'FREQ=' .. parsed.freq:upper() } + if parsed.interval > 1 then + table.insert(parts, 'INTERVAL=' .. parsed.interval) + end + if parsed.byday then + table.insert(parts, 'BYDAY=' .. table.concat(parsed.byday, ',')) + end + return 'RRULE:' .. table.concat(parts, ';') +end + +---@return string[] +function M.shorthand_list() + return { 'daily', 'weekdays', 'weekly', 'biweekly', 'monthly', 'quarterly', 'yearly', 'annual' } +end + +return M diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 5838414..20898fd 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -7,6 +7,8 @@ local config = require('pending.config') ---@field category? string ---@field priority integer ---@field due? string +---@field recur? string +---@field recur_mode? 'scheduled'|'completion' ---@field entry string ---@field modified string ---@field end? string @@ -17,21 +19,28 @@ 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.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 @@ -56,6 +65,8 @@ local known_fields = { category = true, priority = true, due = true, + recur = true, + recur_mode = true, entry = true, modified = true, ['end'] = true, @@ -81,6 +92,12 @@ local function task_to_table(task) if task.due then t.due = task.due end + if task.recur then + t.recur = task.recur + end + if task.recur_mode then + t.recur_mode = task.recur_mode + end if task['end'] then t['end'] = task['end'] end @@ -105,6 +122,8 @@ local function table_to_task(t) category = t.category, priority = t.priority or 0, due = t.due, + recur = t.recur, + recur_mode = t.recur_mode, entry = t.entry, modified = t.modified, ['end'] = t['end'], @@ -123,18 +142,18 @@ local function table_to_task(t) end ---@return pending.Data -function M.load() - local path = config.get().data_path +function Store:load() + local path = self.path local f = io.open(path, 'r') if not f then - _data = empty_data() - return _data + self._data = empty_data() + return self._data end local content = f:read('*a') f:close() if content == '' then - _data = empty_data() - return _data + self._data = empty_data() + return self._data end local ok, decoded = pcall(vim.json.decode, content) if not ok then @@ -149,31 +168,52 @@ function M.load() .. '. Please update the plugin.' ) end - _data = { + self._data = { version = decoded.version or SUPPORTED_VERSION, next_id = decoded.next_id or 1, tasks = {}, + undo = {}, + folded_categories = decoded.folded_categories or {}, } for _, t in ipairs(decoded.tasks or {}) do - table.insert(_data.tasks, table_to_task(t)) + table.insert(self._data.tasks, table_to_task(t)) end - return _data + for _, snapshot in ipairs(decoded.undo or {}) do + if type(snapshot) == 'table' then + local tasks = {} + for _, raw in ipairs(snapshot) do + table.insert(tasks, table_to_task(raw)) + end + table.insert(self._data.undo, tasks) + end + end + return self._data end -function M.save() - if not _data then +---@return nil +function Store:save() + if not self._data then return end - local path = config.get().data_path + local path = self.path ensure_dir(path) local out = { - version = _data.version, - next_id = _data.next_id, + version = self._data.version, + next_id = self._data.next_id, tasks = {}, + undo = {}, + folded_categories = self._data.folded_categories, } - for _, task in ipairs(_data.tasks) do + for _, task in ipairs(self._data.tasks) do table.insert(out.tasks, task_to_table(task)) end + for _, snapshot in ipairs(self._data.undo) do + local serialized = {} + for _, task in ipairs(snapshot) do + table.insert(serialized, task_to_table(task)) + end + table.insert(out.undo, serialized) + end local encoded = vim.json.encode(out) local tmp = path .. '.tmp' local f = io.open(tmp, 'w') @@ -190,22 +230,22 @@ function M.save() end ---@return pending.Data -function M.data() - if not _data then - M.load() +function Store:data() + if not self._data then + self:load() end - return _data --[[@as pending.Data]] + return self._data --[[@as pending.Data]] end ---@return pending.Task[] -function M.tasks() - return M.data().tasks +function Store:tasks() + return self:data().tasks end ---@return pending.Task[] -function M.active_tasks() +function Store:active_tasks() local result = {} - for _, task in ipairs(M.tasks()) do + for _, task in ipairs(self:tasks()) do if task.status ~= 'deleted' then table.insert(result, task) end @@ -215,8 +255,8 @@ end ---@param id integer ---@return pending.Task? -function M.get(id) - for _, task in ipairs(M.tasks()) do +function Store:get(id) + for _, task in ipairs(self:tasks()) do if task.id == id then return task end @@ -224,10 +264,10 @@ function M.get(id) return nil end ----@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, order?: integer, _extra?: table } +---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, recur?: string, recur_mode?: string, order?: integer, _extra?: table } ---@return pending.Task -function M.add(fields) - local data = M.data() +function Store:add(fields) + local data = self:data() local now = timestamp() local task = { id = data.next_id, @@ -236,6 +276,8 @@ function M.add(fields) category = fields.category or config.get().default_category, priority = fields.priority or 0, due = fields.due, + recur = fields.recur, + recur_mode = fields.recur_mode, entry = now, modified = now, ['end'] = nil, @@ -250,15 +292,19 @@ end ---@param id integer ---@param fields table ---@return pending.Task? -function M.update(id, fields) - local task = M.get(id) +function Store:update(id, fields) + local task = self:get(id) if not task then return nil end local now = timestamp() for k, v in pairs(fields) do if k ~= 'id' and k ~= 'entry' then - task[k] = v + if v == vim.NIL then + task[k] = nil + else + task[k] = v + end end end task.modified = now @@ -270,14 +316,14 @@ end ---@param id integer ---@return pending.Task? -function M.delete(id) - return M.update(id, { status = 'deleted', ['end'] = timestamp() }) +function Store:delete(id) + return self:update(id, { status = 'deleted', ['end'] = timestamp() }) end ---@param id integer ---@return integer? -function M.find_index(id) - for i, task in ipairs(M.tasks()) do +function Store:find_index(id) + for i, task in ipairs(self:tasks()) do if task.id == id then return i end @@ -286,14 +332,15 @@ function M.find_index(id) end ---@param tasks pending.Task[] -function M.replace_tasks(tasks) - M.data().tasks = tasks +---@return nil +function Store:replace_tasks(tasks) + self:data().tasks = tasks end ---@return pending.Task[] -function M.snapshot() +function Store:snapshot() local result = {} - for _, task in ipairs(M.active_tasks()) do + for _, task in ipairs(self:active_tasks()) do local copy = {} for k, v in pairs(task) do if k ~= '_extra' then @@ -311,13 +358,48 @@ function M.snapshot() return result end ----@param id integer -function M.set_next_id(id) - M.data().next_id = id +---@return pending.Task[][] +function Store:undo_stack() + return self:data().undo end -function M.unload() - _data = nil +---@param stack pending.Task[][] +---@return nil +function Store:set_undo_stack(stack) + self:data().undo = stack +end + +---@param id integer +---@return nil +function Store:set_next_id(id) + self:data().next_id = id +end + +---@return string[] +function Store:get_folded_categories() + return self:data().folded_categories +end + +---@param cats string[] +---@return nil +function Store:set_folded_categories(cats) + self:data().folded_categories = cats +end + +---@return nil +function Store:unload() + self._data = nil +end + +---@param path string +---@return pending.Store +function M.new(path) + return setmetatable({ path = path, _data = nil }, Store) +end + +---@return string +function M.resolve_path() + return config.get().data_path end return M diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 6635575..80802a7 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -1,384 +1,60 @@ local config = require('pending.config') -local store = require('pending.store') +local log = require('pending.log') +local oauth = require('pending.sync.oauth') local M = {} +M.name = 'gcal' + local BASE_URL = 'https://www.googleapis.com/calendar/v3' -local TOKEN_URL = 'https://oauth2.googleapis.com/token' -local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' -local SCOPE = 'https://www.googleapis.com/auth/calendar' ----@class pending.GcalCredentials ----@field client_id string ----@field client_secret string ----@field redirect_uris? string[] - ----@class pending.GcalTokens ----@field access_token string ----@field refresh_token string ----@field expires_in? integer ----@field obtained_at? integer - ----@return table -local function gcal_config() - local cfg = config.get() - return cfg.gcal or {} -end - ----@return string -local function token_path() - return vim.fn.stdpath('data') .. '/pending/gcal_tokens.json' -end - ----@return string -local function credentials_path() - local gc = gcal_config() - return gc.credentials_path or (vim.fn.stdpath('data') .. '/pending/gcal_credentials.json') -end - ----@param path string ----@return table? -local function load_json_file(path) - local f = io.open(path, 'r') - if not f then - return nil - end - local content = f:read('*a') - f:close() - if content == '' then - return nil - end - local ok, decoded = pcall(vim.json.decode, content) - if not ok then - return nil - end - return decoded -end - ----@param path string ----@param data table ----@return boolean -local function save_json_file(path, data) - local dir = vim.fn.fnamemodify(path, ':h') - if vim.fn.isdirectory(dir) == 0 then - vim.fn.mkdir(dir, 'p') - end - local f = io.open(path, 'w') - if not f then - return false - end - f:write(vim.json.encode(data)) - f:close() - vim.fn.setfperm(path, 'rw-------') - return true -end - ----@return pending.GcalCredentials? -local function load_credentials() - local creds = load_json_file(credentials_path()) - if not creds then - return nil - end - if creds.installed then - return creds.installed --[[@as pending.GcalCredentials]] - end - return creds --[[@as pending.GcalCredentials]] -end - ----@return pending.GcalTokens? -local function load_tokens() - return load_json_file(token_path()) --[[@as pending.GcalTokens?]] -end - ----@param tokens pending.GcalTokens ----@return boolean -local function save_tokens(tokens) - return save_json_file(token_path(), tokens) -end - ----@param str string ----@return string -local function url_encode(str) - return ( - str:gsub('([^%w%-%.%_%~])', function(c) - return string.format('%%%02X', string.byte(c)) - end) +---@param access_token string +---@return table? name_to_id +---@return string? err +local function get_all_calendars(access_token) + local data, err = oauth.curl_request( + 'GET', + BASE_URL .. '/users/me/calendarList', + oauth.auth_headers(access_token) ) -end - ----@param method string ----@param url string ----@param headers? string[] ----@param body? string ----@return table? result ----@return string? err -local function curl_request(method, url, headers, body) - local args = { 'curl', '-s', '-X', method } - for _, h in ipairs(headers or {}) do - table.insert(args, '-H') - table.insert(args, h) - end - if body then - table.insert(args, '-d') - table.insert(args, body) - end - table.insert(args, url) - local result = vim.system(args, { text = true }):wait() - if result.code ~= 0 then - return nil, 'curl failed: ' .. (result.stderr or '') - end - if not result.stdout or result.stdout == '' then - return {}, nil - end - local ok, decoded = pcall(vim.json.decode, result.stdout) - if not ok then - return nil, 'failed to parse response: ' .. result.stdout - end - if decoded.error then - return nil, 'API error: ' .. (decoded.error.message or vim.json.encode(decoded.error)) - end - return decoded, nil -end - ----@param access_token string ----@return string[] -local function auth_headers(access_token) - return { - 'Authorization: Bearer ' .. access_token, - 'Content-Type: application/json', - } -end - ----@param creds pending.GcalCredentials ----@param tokens pending.GcalTokens ----@return pending.GcalTokens? -local function refresh_access_token(creds, tokens) - local body = 'client_id=' - .. url_encode(creds.client_id) - .. '&client_secret=' - .. url_encode(creds.client_secret) - .. '&grant_type=refresh_token' - .. '&refresh_token=' - .. url_encode(tokens.refresh_token) - local result = vim - .system({ - 'curl', - '-s', - '-X', - 'POST', - '-H', - 'Content-Type: application/x-www-form-urlencoded', - '-d', - body, - TOKEN_URL, - }, { text = true }) - :wait() - if result.code ~= 0 then - return nil - end - local ok, decoded = pcall(vim.json.decode, result.stdout or '') - if not ok or not decoded.access_token then - return nil - end - tokens.access_token = decoded.access_token --[[@as string]] - tokens.expires_in = decoded.expires_in --[[@as integer?]] - tokens.obtained_at = os.time() - save_tokens(tokens) - return tokens -end - ----@return string? -local function get_access_token() - local creds = load_credentials() - if not creds then - vim.notify( - 'pending.nvim: No Google Calendar credentials found at ' .. credentials_path(), - vim.log.levels.ERROR - ) - return nil - end - local tokens = load_tokens() - if not tokens or not tokens.refresh_token then - M.authorize() - tokens = load_tokens() - if not tokens then - return nil - end - end - local now = os.time() - local obtained = tokens.obtained_at or 0 - local expires = tokens.expires_in or 3600 - if now - obtained > expires - 60 then - tokens = refresh_access_token(creds, tokens) - if not tokens then - vim.notify('pending.nvim: Failed to refresh access token.', vim.log.levels.ERROR) - return nil - end - end - return tokens.access_token -end - -function M.authorize() - local creds = load_credentials() - if not creds then - vim.notify( - 'pending.nvim: No Google Calendar credentials found at ' .. credentials_path(), - vim.log.levels.ERROR - ) - return - end - - local port = 18392 - local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' - local verifier = {} - math.randomseed(os.time()) - for _ = 1, 64 do - local idx = math.random(1, #verifier_chars) - table.insert(verifier, verifier_chars:sub(idx, idx)) - end - local code_verifier = table.concat(verifier) - - local sha_pipe = vim - .system({ - 'sh', - '-c', - 'printf "%s" "' - .. code_verifier - .. '" | openssl dgst -sha256 -binary | openssl base64 -A | tr "+/" "-_" | tr -d "="', - }, { text = true }) - :wait() - local code_challenge = sha_pipe.stdout or '' - - local auth_url = AUTH_URL - .. '?client_id=' - .. url_encode(creds.client_id) - .. '&redirect_uri=' - .. url_encode('http://127.0.0.1:' .. port) - .. '&response_type=code' - .. '&scope=' - .. url_encode(SCOPE) - .. '&access_type=offline' - .. '&prompt=consent' - .. '&code_challenge=' - .. url_encode(code_challenge) - .. '&code_challenge_method=S256' - - vim.ui.open(auth_url) - vim.notify('pending.nvim: Opening browser for Google authorization...') - - local server = vim.uv.new_tcp() - server:bind('127.0.0.1', port) - server:listen(1, function(err) - if err then - return - end - local client = vim.uv.new_tcp() - server:accept(client) - client:read_start(function(read_err, data) - if read_err or not data then - return - end - local code = data:match('[?&]code=([^&%s]+)') - local response_body = code - and '

Authorization successful

You can close this tab.

' - or '

Authorization failed

' - local http_response = 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n' - .. response_body - client:write(http_response, function() - client:shutdown(function() - client:close() - end) - end) - server:close() - if code then - vim.schedule(function() - M._exchange_code(creds, code, code_verifier, port) - end) - end - end) - end) -end - ----@param creds pending.GcalCredentials ----@param code string ----@param code_verifier string ----@param port integer -function M._exchange_code(creds, code, code_verifier, port) - local body = 'client_id=' - .. url_encode(creds.client_id) - .. '&client_secret=' - .. url_encode(creds.client_secret) - .. '&code=' - .. url_encode(code) - .. '&code_verifier=' - .. url_encode(code_verifier) - .. '&grant_type=authorization_code' - .. '&redirect_uri=' - .. url_encode('http://127.0.0.1:' .. port) - - local result = vim - .system({ - 'curl', - '-s', - '-X', - 'POST', - '-H', - 'Content-Type: application/x-www-form-urlencoded', - '-d', - body, - TOKEN_URL, - }, { text = true }) - :wait() - - if result.code ~= 0 then - vim.notify('pending.nvim: Token exchange failed.', vim.log.levels.ERROR) - return - end - - local ok, decoded = pcall(vim.json.decode, result.stdout or '') - if not ok or not decoded.access_token then - vim.notify('pending.nvim: Invalid token response.', vim.log.levels.ERROR) - return - end - - decoded.obtained_at = os.time() - save_tokens(decoded) - vim.notify('pending.nvim: Google Calendar authorized successfully.') -end - ----@param access_token string ----@return string? calendar_id ----@return string? err -local function find_or_create_calendar(access_token) - local gc = gcal_config() - local cal_name = gc.calendar or 'Pendings' - - local data, err = - curl_request('GET', BASE_URL .. '/users/me/calendarList', auth_headers(access_token)) if err then return nil, err end - + local result = {} for _, item in ipairs(data and data.items or {}) do - if item.summary == cal_name then - return item.id, nil + if item.summary then + result[item.summary] = item.id end end + return result, nil +end - local body = vim.json.encode({ summary = cal_name }) - local created, create_err = - curl_request('POST', BASE_URL .. '/calendars', auth_headers(access_token), body) - if create_err then - return nil, create_err +---@param access_token string +---@param name string +---@param existing table +---@return string? calendar_id +---@return string? err +local function find_or_create_calendar(access_token, name, existing) + if existing[name] then + return existing[name], nil end - - return created and created.id, nil + local body = vim.json.encode({ summary = name }) + local created, err = + oauth.curl_request('POST', BASE_URL .. '/calendars', oauth.auth_headers(access_token), body) + if err then + return nil, err + end + local id = created and created.id + if id then + existing[name] = id + end + return id, nil end ---@param date_str string ---@return string local function next_day(date_str) - local y, m, d = date_str:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') + local y, m, d = date_str:match('^(%d%d%d%d)-(%d%d)-(%d%d)') local t = os.time({ year = tonumber(y) or 0, month = tonumber(m) or 0, day = tonumber(d) or 0 }) + 86400 return os.date('%Y-%m-%d', t) --[[@as string]] @@ -399,10 +75,10 @@ local function create_event(access_token, calendar_id, task) private = { taskId = tostring(task.id) }, }, } - local data, err = curl_request( + local data, err = oauth.curl_request( 'POST', - BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events', - auth_headers(access_token), + BASE_URL .. '/calendars/' .. oauth.url_encode(calendar_id) .. '/events', + oauth.auth_headers(access_token), vim.json.encode(event) ) if err then @@ -421,11 +97,16 @@ 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 = curl_request( + local _, err = oauth.curl_request( 'PATCH', - BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id), - auth_headers(access_token), + BASE_URL + .. '/calendars/' + .. oauth.url_encode(calendar_id) + .. '/events/' + .. oauth.url_encode(event_id), + oauth.auth_headers(access_token), vim.json.encode(event) ) return err @@ -436,81 +117,159 @@ end ---@param event_id string ---@return string? err local function delete_event(access_token, calendar_id, event_id) - local _, err = curl_request( + local _, err = oauth.curl_request( 'DELETE', - BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id), - auth_headers(access_token) + BASE_URL + .. '/calendars/' + .. oauth.url_encode(calendar_id) + .. '/events/' + .. oauth.url_encode(event_id), + oauth.auth_headers(access_token) ) return err end -function M.sync() - local access_token = get_access_token() - if not access_token then - return +---@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 end + return sync.remote_delete == true +end - local calendar_id, err = find_or_create_calendar(access_token) - if err or not calendar_id then - vim.notify('pending.nvim: ' .. (err or 'calendar not found'), vim.log.levels.ERROR) - return +---@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 end + task.modified = now_ts +end - local tasks = store.tasks() - local created, updated, deleted = 0, 0, 0 +---@param parts {[1]: integer, [2]: string}[] +---@return string +local function fmt_counts(parts) + local items = {} + for _, p in ipairs(parts) do + if p[1] > 0 then + table.insert(items, p[1] .. ' ' .. p[2]) + end + end + if #items == 0 then + return 'nothing to do' + end + return table.concat(items, ' | ') +end - for _, task in ipairs(tasks) do - local extra = task._extra or {} - local event_id = extra['_gcal_event_id'] --[[@as string?]] +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 should_delete = event_id ~= nil - and ( - task.status == 'done' - or task.status == 'deleted' - or (task.status == 'pending' and not task.due) - ) + 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 - 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 - task._extra = extra - end - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] - deleted = deleted + 1 - end - elseif task.status == 'pending' and task.due then - if event_id then - local upd_err = update_event(access_token, calendar_id, event_id, task) - if not upd_err then - updated = updated + 1 - end - else - local new_id, create_err = create_event(access_token, calendar_id, task) - if not create_err and new_id then - if not task._extra then - task._extra = {} + 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 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 + else + log.debug( + 'gcal: remote delete skipped (remote_delete disabled) — unlinked task #' .. task.id + ) + unlink_remote(task, extra, now_ts) + deleted = deleted + 1 + end + elseif task.status == 'pending' and task.due then + local cat = task.category or config.get().default_category + if event_id and cal_id then + local upd_err = update_event(access_token, cal_id, event_id, task) + if upd_err then + log.warn('Failed to update calendar event: ' .. upd_err) + failed = failed + 1 + else + updated = updated + 1 + end + else + local lid, lid_err = find_or_create_calendar(access_token, cat, calendars) + if lid_err or not lid then + log.warn('Failed to create calendar: ' .. (lid_err or 'unknown')) + failed = failed + 1 + else + local new_id, create_err = create_event(access_token, lid, task) + if create_err then + log.warn('Failed to create calendar event: ' .. create_err) + failed = failed + 1 + elseif new_id then + if not task._extra then + task._extra = {} + end + task._extra['_gcal_event_id'] = new_id + task._extra['_gcal_calendar_id'] = lid + task.modified = now_ts + created = created + 1 + end end - 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 - store.save() - vim.notify( - string.format( - 'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)', - created, - updated, - deleted - ) - ) + s:save() + require('pending')._recompute_counts() + local buffer = require('pending.buffer') + if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then + buffer.render(buffer.bufnr()) + end + log.info('gcal push: ' .. fmt_counts({ + { created, 'added' }, + { updated, 'updated' }, + { deleted, 'removed' }, + { failed, 'failed' }, + })) + end) +end + +---@return nil +function M.health() + oauth.health(M.name) + local tokens = oauth.google_client:load_tokens() + if tokens and tokens.refresh_token then + vim.health.ok('gcal tokens found') + else + vim.health.info('no gcal tokens — run :Pending auth') + end end return M diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua new file mode 100644 index 0000000..014e80a --- /dev/null +++ b/lua/pending/sync/gtasks.lua @@ -0,0 +1,547 @@ +local config = require('pending.config') +local log = require('pending.log') +local oauth = require('pending.sync.oauth') + +local M = {} + +M.name = 'gtasks' + +local BASE_URL = 'https://tasks.googleapis.com/tasks/v1' + +---@param access_token string +---@return table? 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 parts {[1]: integer, [2]: string}[] +---@return string +local function fmt_counts(parts) + local items = {} + for _, p in ipairs(parts) do + if p[1] > 0 then + table.insert(items, p[1] .. ' ' .. p[2]) + end + end + if #items == 0 then + return 'nothing to do' + end + return table.concat(items, ' | ') +end + +---@param task pending.Task +---@return table +local function task_to_gtask(task) + local body = { + title = task.description, + status = task.status == 'done' and 'completed' or 'needsAction', + } + if task.due then + body.due = due_to_rfc3339(task.due) + end + local notes = build_notes(task) + if notes then + body.notes = notes + end + return body +end + +---@param gtask table +---@param category string +---@return table fields for store:add / store:update +local function gtask_to_fields(gtask, category) + local priority, recur, recur_mode = parse_notes(gtask.notes) + local fields = { + description = gtask.title or '', + category = category, + status = gtask.status == 'completed' and 'done' or 'pending', + priority = priority, + recur = recur, + recur_mode = recur_mode, + } + if gtask.due then + fields.due = rfc3339_to_date(gtask.due) + end + return fields +end + +---@param s pending.Store +---@return table +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) + s:save() + require('pending')._recompute_counts() + local buffer = require('pending.buffer') + if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then + buffer.render(buffer.bufnr()) + end + log.info('gtasks push: ' .. fmt_counts({ + { created, 'added' }, + { updated, 'updated' }, + { deleted, 'deleted' }, + { failed, 'failed' }, + })) + end) +end + +function M.pull() + oauth.with_token(oauth.google_client, 'gtasks', function(access_token) + local tasklists, s, now_ts = sync_setup(access_token) + if not tasklists then + return + end + ---@cast s pending.Store + ---@cast now_ts string + local by_gtasks_id = build_id_index(s) + local created, updated, failed, seen_remote_ids, fetched_list_ids = + pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local unlinked = detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts) + s:save() + require('pending')._recompute_counts() + local buffer = require('pending.buffer') + if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then + buffer.render(buffer.bufnr()) + end + log.info('gtasks pull: ' .. fmt_counts({ + { created, 'added' }, + { updated, 'updated' }, + { unlinked, 'unlinked' }, + { failed, 'failed' }, + })) + end) +end + +function M.sync() + oauth.with_token(oauth.google_client, 'gtasks', function(access_token) + local tasklists, s, now_ts = sync_setup(access_token) + if not tasklists then + return + end + ---@cast s pending.Store + ---@cast now_ts string + local by_gtasks_id = build_id_index(s) + local pushed_create, pushed_update, pushed_delete, pushed_failed = + push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local pulled_create, pulled_update, pulled_failed, seen_remote_ids, fetched_list_ids = + pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local unlinked = detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts) + s:save() + require('pending')._recompute_counts() + local buffer = require('pending.buffer') + if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then + buffer.render(buffer.bufnr()) + end + log.info('gtasks sync — push: ' .. fmt_counts({ + { pushed_create, 'added' }, + { pushed_update, 'updated' }, + { pushed_delete, 'deleted' }, + { pushed_failed, 'failed' }, + }) .. ' pull: ' .. fmt_counts({ + { pulled_create, 'added' }, + { pulled_update, 'updated' }, + { unlinked, 'unlinked' }, + { pulled_failed, 'failed' }, + })) + end) +end + +M._due_to_rfc3339 = due_to_rfc3339 +M._rfc3339_to_date = rfc3339_to_date +M._build_notes = build_notes +M._parse_notes = parse_notes +M._task_to_gtask = task_to_gtask +M._gtask_to_fields = gtask_to_fields +M._push_pass = push_pass +M._pull_pass = pull_pass +M._detect_remote_deletions = detect_remote_deletions + +---@return nil +function M.health() + oauth.health(M.name) + local tokens = oauth.google_client:load_tokens() + if tokens and tokens.refresh_token then + vim.health.ok('gtasks tokens found') + else + vim.health.info('no gtasks tokens — run :Pending auth') + end +end + +return M diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua new file mode 100644 index 0000000..dabbe2d --- /dev/null +++ b/lua/pending/sync/oauth.lua @@ -0,0 +1,572 @@ +local config = require('pending.config') +local log = require('pending.log') + +local TOKEN_URL = 'https://oauth2.googleapis.com/token' +local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' + +local BUNDLED_CLIENT_ID = 'PLACEHOLDER' +local BUNDLED_CLIENT_SECRET = 'PLACEHOLDER' + +---@class pending.OAuthCredentials +---@field client_id string +---@field client_secret string + +---@class pending.OAuthTokens +---@field access_token string +---@field refresh_token string +---@field expires_in? integer +---@field obtained_at? integer + +---@class pending.OAuthClient +---@field name string +---@field scope string +---@field port integer +---@field config_key string +local OAuthClient = {} +OAuthClient.__index = OAuthClient + +local _active_close = nil +local _sync_in_flight = false + +---@class pending.oauth +local M = {} + +---@param args string[] +---@param opts? table +---@return { code: integer, stdout: string, stderr: string } +function M.system(args, opts) + local co = coroutine.running() + if not co then + return vim.system(args, opts or {}):wait() --[[@as { code: integer, stdout: string, stderr: string }]] + end + vim.system(args, opts or {}, function(result) + vim.schedule(function() + coroutine.resume(co, result) + end) + end) + return coroutine.yield() --[[@as { code: integer, stdout: string, stderr: string }]] +end + +---@param fn fun(): nil +function M.async(fn) + coroutine.resume(coroutine.create(fn)) +end + +---@param client pending.OAuthClient +---@param name string +---@param callback fun(access_token: string): nil +function M.with_token(client, name, callback) + if _sync_in_flight then + require('pending.log').warn(name .. ': Sync already in progress — please wait.') + return + end + _sync_in_flight = true + M.async(function() + local token = client:get_access_token() + if not token then + _sync_in_flight = false + require('pending.log').warn(name .. ': Not authenticated — run :Pending auth.') + return + end + local ok, err = pcall(callback, token) + _sync_in_flight = false + if not ok then + require('pending.log').error(name .. ': ' .. tostring(err)) + end + end) +end + +---@param str string +---@return string +function M.url_encode(str) + return ( + str:gsub('([^%w%-%.%_%~])', function(c) + return string.format('%%%02X', string.byte(c)) + end) + ) +end + +---@param path string +---@return table? +function M.load_json_file(path) + local f = io.open(path, 'r') + if not f then + return nil + end + local content = f:read('*a') + f:close() + if content == '' then + return nil + end + local ok, decoded = pcall(vim.json.decode, content) + if not ok then + return nil + end + return decoded +end + +---@param path string +---@param data table +---@return boolean +function M.save_json_file(path, data) + local dir = vim.fn.fnamemodify(path, ':h') + if vim.fn.isdirectory(dir) == 0 then + vim.fn.mkdir(dir, 'p') + end + local f = io.open(path, 'w') + if not f then + return false + end + f:write(vim.json.encode(data)) + f:close() + vim.fn.setfperm(path, 'rw-------') + return true +end + +---@param method string +---@param url string +---@param headers? string[] +---@param body? string +---@return table? result +---@return string? err +function M.curl_request(method, url, headers, body) + local args = { 'curl', '-s', '-X', method } + for _, h in ipairs(headers or {}) do + table.insert(args, '-H') + table.insert(args, h) + end + if body then + table.insert(args, '-d') + table.insert(args, body) + end + table.insert(args, url) + local result = M.system(args, { text = true }) + if result.code ~= 0 then + return nil, 'curl failed: ' .. (result.stderr or '') + end + if not result.stdout or result.stdout == '' then + return {}, nil + end + local ok, decoded = pcall(vim.json.decode, result.stdout) + if not ok then + return nil, 'failed to parse response: ' .. result.stdout + end + if decoded.error then + return nil, 'API error: ' .. (decoded.error.message or vim.json.encode(decoded.error)) + end + return decoded, nil +end + +---@param access_token string +---@return string[] +function M.auth_headers(access_token) + return { + 'Authorization: Bearer ' .. access_token, + 'Content-Type: application/json', + } +end + +---@param backend_name string +---@return nil +function M.health(backend_name) + if vim.fn.executable('curl') == 1 then + vim.health.ok('curl found (required for ' .. backend_name .. ' sync)') + else + vim.health.warn('curl not found (needed for ' .. backend_name .. ' sync)') + end +end + +---@return string +function OAuthClient:token_path() + return vim.fn.stdpath('data') .. '/pending/' .. self.name .. '_tokens.json' +end + +---@return pending.OAuthCredentials +function OAuthClient:resolve_credentials() + local cfg = config.get() + local backend_cfg = (cfg.sync and cfg.sync[self.config_key]) or {} + + if backend_cfg.client_id and backend_cfg.client_secret then + return { + client_id = backend_cfg.client_id, + client_secret = backend_cfg.client_secret, + } + end + + local data_dir = vim.fn.stdpath('data') .. '/pending/' + local cred_paths = {} + if backend_cfg.credentials_path then + table.insert(cred_paths, backend_cfg.credentials_path) + end + table.insert(cred_paths, data_dir .. self.name .. '_credentials.json') + table.insert(cred_paths, data_dir .. 'google_credentials.json') + for _, cred_path in ipairs(cred_paths) do + if cred_path then + local creds = M.load_json_file(cred_path) + if creds then + if creds.installed then + creds = creds.installed + end + if creds.client_id and creds.client_secret then + return creds --[[@as pending.OAuthCredentials]] + end + end + end + end + return { + client_id = BUNDLED_CLIENT_ID, + client_secret = BUNDLED_CLIENT_SECRET, + } +end + +---@return pending.OAuthTokens? +function OAuthClient:load_tokens() + return M.load_json_file(self:token_path()) --[[@as pending.OAuthTokens?]] +end + +---@param tokens pending.OAuthTokens +---@return boolean +function OAuthClient:save_tokens(tokens) + return M.save_json_file(self:token_path(), tokens) +end + +---@param creds pending.OAuthCredentials +---@param tokens pending.OAuthTokens +---@return pending.OAuthTokens? +function OAuthClient:refresh_access_token(creds, tokens) + local body = 'client_id=' + .. M.url_encode(creds.client_id) + .. '&client_secret=' + .. M.url_encode(creds.client_secret) + .. '&grant_type=refresh_token' + .. '&refresh_token=' + .. M.url_encode(tokens.refresh_token) + local result = M.system({ + 'curl', + '-s', + '-X', + 'POST', + '-H', + 'Content-Type: application/x-www-form-urlencoded', + '-d', + body, + TOKEN_URL, + }, { text = true }) + if result.code ~= 0 then + return nil + end + local ok, decoded = pcall(vim.json.decode, result.stdout or '') + if not ok or not decoded.access_token then + return nil + end + tokens.access_token = decoded.access_token --[[@as string]] + tokens.expires_in = decoded.expires_in --[[@as integer?]] + tokens.obtained_at = os.time() + self:save_tokens(tokens) + return tokens +end + +---@return string? +function OAuthClient:get_access_token() + local creds = self:resolve_credentials() + local tokens = self:load_tokens() + if not tokens or not tokens.refresh_token then + return nil + end + local now = os.time() + local obtained = tokens.obtained_at or 0 + local expires = tokens.expires_in or 3600 + if now - obtained > expires - 60 then + tokens = self:refresh_access_token(creds, tokens) + if not tokens then + log.error(self.name .. ': Token refresh failed — re-authenticating...') + return nil + end + end + return tokens.access_token +end + +---@return nil +function OAuthClient:setup() + local choice = vim.fn.inputlist({ + self.name .. ' setup:', + '1. Enter client ID and secret', + '2. Load from JSON file path', + }) + vim.cmd.redraw() + + local id, secret + + if choice == 1 then + while true do + id = vim.trim(vim.fn.input(self.name .. ' client ID: ')) + if id == '' then + return + end + if id:match('^%d+%-[%w_]+%.apps%.googleusercontent%.com$') then + break + end + vim.cmd.redraw() + vim.api.nvim_echo({ + { + 'invalid client ID — expected -.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 { name: string, scope: string, port: integer, config_key: string } +---@return pending.OAuthClient +function M.new(opts) + return setmetatable({ + name = opts.name, + scope = opts.scope, + port = opts.port, + config_key = opts.config_key, + }, OAuthClient) +end + +M._BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID +M._BUNDLED_CLIENT_SECRET = BUNDLED_CLIENT_SECRET +M.BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID + +M.google_client = M.new({ + name = 'google', + scope = 'https://www.googleapis.com/auth/tasks' .. ' https://www.googleapis.com/auth/calendar', + port = 18392, + config_key = 'google', +}) + +return M diff --git a/lua/pending/textobj.lua b/lua/pending/textobj.lua new file mode 100644 index 0000000..887ef8f --- /dev/null +++ b/lua/pending/textobj.lua @@ -0,0 +1,383 @@ +local buffer = require('pending.buffer') +local config = require('pending.config') +local log = require('pending.log') + +---@class pending.textobj +local M = {} + +---@param ... any +---@return nil +local function dbg(...) + log.debug(string.format(...)) +end + +---@param lnum integer +---@param meta pending.LineMeta[] +---@return string +local function get_line_from_buf(lnum, meta) + local _ = meta + local bufnr = buffer.bufnr() + if not bufnr then + return '' + end + local lines = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, false) + return lines[1] or '' +end + +---@param line string +---@return integer start_col +---@return integer end_col +function M.inner_task_range(line) + local prefix_end = line:find('/') and select(2, line:find('^/%d+/%- %[.%] ')) + if not prefix_end then + prefix_end = select(2, line:find('^%- %[.%] ')) or 0 + end + local start_col = prefix_end + 1 + + local dk = config.get().date_syntax or 'due' + local rk = config.get().recur_syntax or 'rec' + local dk_pat = '^' .. vim.pesc(dk) .. ':%S+$' + local rk_pat = '^' .. vim.pesc(rk) .. ':%S+$' + + local rest = line:sub(start_col) + local words = {} + for word in rest:gmatch('%S+') do + table.insert(words, word) + end + + local i = #words + while i >= 1 do + local word = words[i] + if word:match(dk_pat) or word:match('^cat:%S+$') or word:match(rk_pat) then + i = i - 1 + else + break + end + end + + if i < 1 then + return start_col, start_col + end + + local desc = table.concat(words, ' ', 1, i) + local end_col = start_col + #desc - 1 + return start_col, end_col +end + +---@param row integer +---@param meta pending.LineMeta[] +---@return integer? header_row +---@return integer? last_row +function M.category_bounds(row, meta) + if not meta or #meta == 0 then + return nil, nil + end + + local header_row = nil + local m = meta[row] + if not m then + return nil, nil + end + + if m.type == 'header' then + header_row = row + else + for r = row, 1, -1 do + if meta[r] and meta[r].type == 'header' then + header_row = r + break + end + end + end + + if not header_row then + return nil, nil + end + + local last_row = header_row + local total = #meta + for r = header_row + 1, total do + if meta[r].type == 'header' then + break + end + last_row = r + end + + return header_row, last_row +end + +---@param count integer +---@return nil +function M.a_task(count) + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local m = meta[row] + if not m or m.type ~= 'task' then + return + end + + local start_row = row + local end_row = row + count = math.max(1, count) + for _ = 2, count do + local next_row = end_row + 1 + if next_row > #meta then + break + end + if meta[next_row] and meta[next_row].type == 'task' then + end_row = next_row + else + break + end + end + + vim.cmd('normal! ' .. start_row .. 'GV' .. end_row .. 'G') +end + +---@param count integer +---@return nil +function M.a_task_visual(count) + vim.cmd('normal! \27') + M.a_task(count) +end + +---@param count integer +---@return nil +function M.i_task(count) + local _ = count + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local m = meta[row] + if not m or m.type ~= 'task' then + return + end + + local line = get_line_from_buf(row, meta) + local start_col, end_col = M.inner_task_range(line) + if start_col > end_col then + return + end + + vim.api.nvim_win_set_cursor(0, { row, start_col - 1 }) + vim.cmd('normal! v') + vim.api.nvim_win_set_cursor(0, { row, end_col - 1 }) +end + +---@param count integer +---@return nil +function M.i_task_visual(count) + vim.cmd('normal! \27') + M.i_task(count) +end + +---@param count integer +---@return nil +function M.a_category(count) + local _ = count + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + local view = buffer.current_view_name() + if view == 'priority' then + return + end + + local row = vim.api.nvim_win_get_cursor(0)[1] + local header_row, last_row = M.category_bounds(row, meta) + if not header_row or not last_row then + return + end + + local start_row = header_row + if header_row > 1 and meta[header_row - 1] and meta[header_row - 1].type == 'blank' then + start_row = header_row - 1 + end + local end_row = last_row + if last_row < #meta and meta[last_row + 1] and meta[last_row + 1].type == 'blank' then + end_row = last_row + 1 + end + + vim.cmd('normal! ' .. start_row .. 'GV' .. end_row .. 'G') +end + +---@param count integer +---@return nil +function M.a_category_visual(count) + vim.cmd('normal! \27') + M.a_category(count) +end + +---@param count integer +---@return nil +function M.i_category(count) + local _ = count + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + local view = buffer.current_view_name() + if view == 'priority' then + return + end + + local row = vim.api.nvim_win_get_cursor(0)[1] + local header_row, last_row = M.category_bounds(row, meta) + if not header_row or not last_row then + return + end + + local first_task = nil + local last_task = nil + for r = header_row + 1, last_row do + if meta[r] and meta[r].type == 'task' then + if not first_task then + first_task = r + end + last_task = r + end + end + + if not first_task or not last_task then + return + end + + vim.cmd('normal! ' .. first_task .. 'GV' .. last_task .. 'G') +end + +---@param count integer +---@return nil +function M.i_category_visual(count) + vim.cmd('normal! \27') + M.i_category(count) +end + +---@param count integer +---@return nil +function M.next_header(count) + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + local view = buffer.current_view_name() + if view == 'priority' then + return + end + + local row = vim.api.nvim_win_get_cursor(0)[1] + dbg('next_header: cursor=%d, meta_len=%d, view=%s', row, #meta, view or 'nil') + local found = 0 + count = math.max(1, count) + for r = row + 1, #meta do + if meta[r] and meta[r].type == 'header' then + found = found + 1 + dbg( + 'next_header: found header at row=%d, cat=%s, found=%d/%d', + r, + meta[r].category or '?', + found, + count + ) + if found == count then + vim.api.nvim_win_set_cursor(0, { r, 0 }) + dbg('next_header: cursor set to row=%d, actual=%d', r, vim.api.nvim_win_get_cursor(0)[1]) + return + end + else + dbg('next_header: row=%d type=%s', r, meta[r] and meta[r].type or 'nil') + end + end + dbg('next_header: no header found after row=%d', row) +end + +---@param count integer +---@return nil +function M.prev_header(count) + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + local view = buffer.current_view_name() + if view == 'priority' then + return + end + + local row = vim.api.nvim_win_get_cursor(0)[1] + dbg('prev_header: cursor=%d, meta_len=%d', row, #meta) + local found = 0 + count = math.max(1, count) + for r = row - 1, 1, -1 do + if meta[r] and meta[r].type == 'header' then + found = found + 1 + dbg( + 'prev_header: found header at row=%d, cat=%s, found=%d/%d', + r, + meta[r].category or '?', + found, + count + ) + if found == count then + vim.api.nvim_win_set_cursor(0, { r, 0 }) + return + end + end + end +end + +---@param count integer +---@return nil +function M.next_task(count) + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + + local row = vim.api.nvim_win_get_cursor(0)[1] + dbg('next_task: cursor=%d, meta_len=%d', row, #meta) + local found = 0 + count = math.max(1, count) + for r = row + 1, #meta do + if meta[r] and meta[r].type == 'task' then + found = found + 1 + if found == count then + dbg('next_task: jumping to row=%d', r) + vim.api.nvim_win_set_cursor(0, { r, 0 }) + return + end + end + end + dbg('next_task: no task found after row=%d', row) +end + +---@param count integer +---@return nil +function M.prev_task(count) + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + + local row = vim.api.nvim_win_get_cursor(0)[1] + dbg('prev_task: cursor=%d, meta_len=%d', row, #meta) + local found = 0 + count = math.max(1, count) + for r = row - 1, 1, -1 do + if meta[r] and meta[r].type == 'task' then + found = found + 1 + if found == count then + dbg('prev_task: jumping to row=%d', r) + vim.api.nvim_win_set_cursor(0, { r, 0 }) + return + end + end + end + dbg('prev_task: no task found before row=%d', row) +end + +return M diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 7bcfaca..3f7a4cf 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -1,7 +1,8 @@ local config = require('pending.config') +local parse = require('pending.parse') ---@class pending.LineMeta ----@field type 'task'|'header'|'blank' +---@field type 'task'|'header'|'blank'|'filter' ---@field id? integer ---@field due? string ---@field raw_due? string @@ -10,6 +11,7 @@ local config = require('pending.config') ---@field overdue? boolean ---@field show_category? boolean ---@field priority? integer +---@field recur? string ---@class pending.views local M = {} @@ -20,7 +22,10 @@ local function format_due(due) if not due then return nil end - local y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') + local y, m, d, hh, mm = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d)$') + if not y then + y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') + end if not y then return due end @@ -29,7 +34,11 @@ local function format_due(due) month = tonumber(m) --[[@as integer]], day = tonumber(d) --[[@as integer]], }) - return os.date(config.get().date_format, t) --[[@as string]] + local formatted = os.date(config.get().date_format, t) --[[@as string]] + if hh then + formatted = formatted .. ' ' .. hh .. ':' .. mm + end + return formatted end ---@param tasks pending.Task[] @@ -73,7 +82,6 @@ end ---@return string[] lines ---@return pending.LineMeta[] meta function M.category_view(tasks) - local today = os.date('%Y-%m-%d') --[[@as string]] local by_cat = {} local cat_order = {} local cat_seen = {} @@ -94,7 +102,7 @@ function M.category_view(tasks) end end - local cfg_order = config.get().category_order + local cfg_order = config.get().view.category.order if cfg_order and #cfg_order > 0 then local ordered = {} local seen = {} @@ -125,7 +133,7 @@ function M.category_view(tasks) table.insert(lines, '') table.insert(meta, { type = 'blank' }) end - table.insert(lines, '## ' .. cat) + table.insert(lines, '# ' .. cat) table.insert(meta, { type = 'header', category = cat }) local all = {} @@ -148,7 +156,10 @@ function M.category_view(tasks) raw_due = task.due, status = task.status, category = cat, - overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil, + priority = task.priority, + overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) + or nil, + recur = task.recur, }) end end @@ -160,7 +171,6 @@ end ---@return string[] lines ---@return pending.LineMeta[] meta function M.priority_view(tasks) - local today = os.date('%Y-%m-%d') --[[@as string]] local pending = {} local done = {} @@ -198,8 +208,10 @@ function M.priority_view(tasks) raw_due = task.due, status = task.status, category = task.category, - overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil, + priority = task.priority, + overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) or nil, show_category = true, + recur = task.recur, }) end diff --git a/plugin/pending.lua b/plugin/pending.lua index 465ee65..e456f09 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -3,16 +3,250 @@ if vim.g.loaded_pending then end vim.g.loaded_pending = true +---@return string[] +local function edit_field_candidates() + local cfg = require('pending.config').get() + local dk = cfg.date_syntax or 'due' + local rk = cfg.recur_syntax or 'rec' + return { + dk .. ':', + 'cat:', + rk .. ':', + '+!', + '-!', + '-' .. dk, + '-cat', + '-' .. rk, + } +end + +---@return string[] +local function edit_date_values() + return { + 'today', + 'tomorrow', + 'yesterday', + '+1d', + '+2d', + '+3d', + '+1w', + '+2w', + '+1m', + 'mon', + 'tue', + 'wed', + 'thu', + 'fri', + 'sat', + 'sun', + 'eod', + 'eow', + 'eom', + 'eoq', + 'eoy', + 'sow', + 'som', + 'soq', + 'soy', + 'later', + } +end + +---@return string[] +local function edit_recur_values() + local ok, recur = pcall(require, 'pending.recur') + if not ok then + return {} + end + local result = {} + for _, s in ipairs(recur.shorthand_list()) do + table.insert(result, s) + end + for _, s in ipairs(recur.shorthand_list()) do + table.insert(result, '!' .. s) + end + return result +end + +---@param lead string +---@param candidates string[] +---@return string[] +local function filter_candidates(lead, candidates) + return vim.tbl_filter(function(s) + return s:find(lead, 1, true) == 1 + end, candidates) +end + +---@param arg_lead string +---@param cmd_line string +---@return string[] +local function complete_edit(arg_lead, cmd_line) + local cfg = require('pending.config').get() + local dk = cfg.date_syntax or 'due' + local rk = cfg.recur_syntax or 'rec' + + local after_edit = cmd_line:match('^Pending%s+edit%s+(.*)') + if not after_edit then + return {} + end + + local parts = {} + for part in after_edit:gmatch('%S+') do + table.insert(parts, part) + end + + local trailing_space = after_edit:match('%s$') + if #parts == 0 or (#parts == 1 and not trailing_space) then + local store = require('pending.store') + local s = store.new(store.resolve_path()) + s:load() + local ids = {} + for _, task in ipairs(s:active_tasks()) do + table.insert(ids, tostring(task.id)) + end + return filter_candidates(arg_lead, ids) + end + + local prefix = arg_lead:match('^(' .. vim.pesc(dk) .. ':)(.*)$') + if prefix then + local after_colon = arg_lead:sub(#prefix + 1) + local dates = edit_date_values() + local result = {} + for _, d in ipairs(dates) do + if d:find(after_colon, 1, true) == 1 then + table.insert(result, prefix .. d) + end + end + return result + end + + local rec_prefix = arg_lead:match('^(' .. vim.pesc(rk) .. ':)(.*)$') + if rec_prefix then + local after_colon = arg_lead:sub(#rec_prefix + 1) + local pats = edit_recur_values() + local result = {} + for _, p in ipairs(pats) do + if p:find(after_colon, 1, true) == 1 then + table.insert(result, rec_prefix .. p) + end + end + return result + end + + local cat_prefix = arg_lead:match('^(cat:)(.*)$') + if cat_prefix then + local after_colon = arg_lead:sub(#cat_prefix + 1) + local store = require('pending.store') + local s = store.new(store.resolve_path()) + s:load() + local seen = {} + local cats = {} + for _, task in ipairs(s:active_tasks()) do + if task.category and not seen[task.category] then + seen[task.category] = true + table.insert(cats, task.category) + end + end + table.sort(cats) + local result = {} + for _, c in ipairs(cats) do + if c:find(after_colon, 1, true) == 1 then + table.insert(result, cat_prefix .. c) + end + end + return result + end + + return filter_candidates(arg_lead, edit_field_candidates()) +end + vim.api.nvim_create_user_command('Pending', function(opts) require('pending').command(opts.args) end, { + bar = true, nargs = '*', complete = function(arg_lead, cmd_line) - local subcmds = { 'add', 'sync', 'archive', 'due', 'undo' } + local pending = require('pending') + local subcmds = { 'add', 'archive', 'auth', 'done', 'due', 'edit', 'filter', 'undo' } + for _, b in ipairs(pending.sync_backends()) do + table.insert(subcmds, b) + end + table.sort(subcmds) if not cmd_line:match('^Pending%s+%S') then - return vim.tbl_filter(function(s) - return s:find(arg_lead, 1, true) == 1 - end, subcmds) + return filter_candidates(arg_lead, subcmds) + end + if cmd_line:match('^Pending%s+filter') then + local after_filter = cmd_line:match('^Pending%s+filter%s+(.*)') or '' + local used = {} + for word in after_filter:gmatch('%S+') do + used[word] = true + end + local candidates = { 'clear', 'overdue', 'today', 'priority', 'done', 'pending' } + local store = require('pending.store') + local s = store.new(store.resolve_path()) + s:load() + local seen = {} + for _, task in ipairs(s:active_tasks()) do + if task.category and not seen[task.category] then + seen[task.category] = true + table.insert(candidates, 'cat:' .. task.category) + end + end + local filtered = {} + for _, c in ipairs(candidates) do + if not used[c] and (arg_lead == '' or c:find(arg_lead, 1, true) == 1) then + table.insert(filtered, c) + end + end + return filtered + end + if cmd_line:match('^Pending%s+done%s') then + local store = require('pending.store') + local s = store.new(store.resolve_path()) + s:load() + local ids = {} + for _, task in ipairs(s:active_tasks()) do + table.insert(ids, tostring(task.id)) + end + return filter_candidates(arg_lead, ids) + end + if cmd_line:match('^Pending%s+edit') then + return complete_edit(arg_lead, cmd_line) + end + if cmd_line:match('^Pending%s+auth') then + local after_auth = cmd_line:match('^Pending%s+auth%s+(.*)') or '' + local parts = {} + for w in after_auth:gmatch('%S+') do + table.insert(parts, w) + end + local trailing = after_auth:match('%s$') + if #parts == 0 or (#parts == 1 and not trailing) then + return filter_candidates(arg_lead, { 'gcal', 'gtasks', 'clear', 'reset' }) + end + if #parts == 1 or (#parts == 2 and not trailing) then + return filter_candidates(arg_lead, { 'clear', 'reset' }) + end + return {} + end + local backend_set = pending.sync_backend_set() + local matched_backend = cmd_line:match('^Pending%s+(%S+)') + if matched_backend and backend_set[matched_backend] then + local after_backend = cmd_line:match('^Pending%s+%S+%s+(.*)') + if not after_backend then + return {} + end + local ok, mod = pcall(require, 'pending.sync.' .. matched_backend) + if not ok then + return {} + end + local actions = {} + for k, v in pairs(mod) do + if type(v) == 'function' and k:sub(1, 1) ~= '_' and k ~= 'health' then + table.insert(actions, k) + end + end + table.sort(actions) + return filter_candidates(arg_lead, actions) end return {} end, @@ -22,6 +256,10 @@ vim.keymap.set('n', '(pending-open)', function() require('pending').open() end) +vim.keymap.set('n', '(pending-close)', function() + require('pending.buffer').close() +end) + vim.keymap.set('n', '(pending-toggle)', function() require('pending').toggle_complete() end) @@ -37,3 +275,65 @@ end) vim.keymap.set('n', '(pending-date)', function() require('pending').prompt_date() end) + +vim.keymap.set('n', '(pending-undo)', function() + require('pending').undo_write() +end) + +vim.keymap.set('n', '(pending-filter)', function() + vim.ui.input({ prompt = 'Filter: ' }, function(input) + if input then + require('pending').filter(input) + end + end) +end) + +vim.keymap.set('n', '(pending-open-line)', function() + require('pending.buffer').open_line(false) +end) + +vim.keymap.set('n', '(pending-open-line-above)', function() + require('pending.buffer').open_line(true) +end) + +vim.keymap.set({ 'o', 'x' }, '(pending-a-task)', function() + require('pending.textobj').a_task(vim.v.count1) +end) + +vim.keymap.set({ 'o', 'x' }, '(pending-i-task)', function() + require('pending.textobj').i_task(vim.v.count1) +end) + +vim.keymap.set({ 'o', 'x' }, '(pending-a-category)', function() + require('pending.textobj').a_category(vim.v.count1) +end) + +vim.keymap.set({ 'o', 'x' }, '(pending-i-category)', function() + require('pending.textobj').i_category(vim.v.count1) +end) + +vim.keymap.set({ 'n', 'x', 'o' }, '(pending-next-header)', function() + require('pending.textobj').next_header(vim.v.count1) +end) + +vim.keymap.set({ 'n', 'x', 'o' }, '(pending-prev-header)', function() + require('pending.textobj').prev_header(vim.v.count1) +end) + +vim.keymap.set({ 'n', 'x', 'o' }, '(pending-next-task)', function() + require('pending.textobj').next_task(vim.v.count1) +end) + +vim.keymap.set({ 'n', 'x', 'o' }, '(pending-prev-task)', function() + require('pending.textobj').prev_task(vim.v.count1) +end) + +vim.keymap.set('n', '(pending-tab)', function() + vim.cmd.tabnew() + require('pending').open() +end) + +vim.api.nvim_create_user_command('PendingTab', function() + vim.cmd.tabnew() + require('pending').open() +end, {}) diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..854fe09 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +nix develop --command stylua --check . +git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet +nix develop --command prettier --check . +nix fmt +git diff --exit-code -- '*.nix' +nix develop --command lua-language-server --check lua --configpath "$(pwd)/.luarc.json" --checklevel=Warning +nix develop --command busted diff --git a/spec/archive_spec.lua b/spec/archive_spec.lua index df1a912..e7046fa 100644 --- a/spec/archive_spec.lua +++ b/spec/archive_spec.lua @@ -1,87 +1,96 @@ require('spec.helpers') local config = require('pending.config') -local store = require('pending.store') describe('archive', function() local tmpdir - local pending = require('pending') + local pending before_each(function() tmpdir = vim.fn.tempname() vim.fn.mkdir(tmpdir, 'p') vim.g.pending = { data_path = tmpdir .. '/tasks.json' } config.reset() - store.unload() - store.load() + package.loaded['pending'] = nil + pending = require('pending') + pending.store():load() end) after_each(function() vim.fn.delete(tmpdir, 'rf') vim.g.pending = nil config.reset() + package.loaded['pending'] = nil end) it('removes done tasks completed more than 30 days ago', function() - local t = store.add({ description = 'Old done task' }) - store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + local s = pending.store() + local t = s:add({ description = 'Old done task' }) + s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) pending.archive() - assert.are.equal(0, #store.active_tasks()) + assert.are.equal(0, #s:active_tasks()) end) it('keeps done tasks completed fewer than 30 days ago', function() + local s = pending.store() local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) - local t = store.add({ description = 'Recent done task' }) - store.update(t.id, { status = 'done', ['end'] = recent_end }) + local t = s:add({ description = 'Recent done task' }) + s:update(t.id, { status = 'done', ['end'] = recent_end }) pending.archive() - local active = store.active_tasks() + local active = s:active_tasks() assert.are.equal(1, #active) assert.are.equal('Recent done task', active[1].description) end) it('respects a custom day count', function() + local s = pending.store() local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400)) - local t = store.add({ description = 'Old for 7 days' }) - store.update(t.id, { status = 'done', ['end'] = eight_days_ago }) + local t = s:add({ description = 'Old for 7 days' }) + s:update(t.id, { status = 'done', ['end'] = eight_days_ago }) pending.archive(7) - assert.are.equal(0, #store.active_tasks()) + assert.are.equal(0, #s:active_tasks()) end) it('keeps tasks within the custom day cutoff', function() + local s = pending.store() local five_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) - local t = store.add({ description = 'Recent for 7 days' }) - store.update(t.id, { status = 'done', ['end'] = five_days_ago }) + local t = s:add({ description = 'Recent for 7 days' }) + s:update(t.id, { status = 'done', ['end'] = five_days_ago }) pending.archive(7) - local active = store.active_tasks() + local active = s:active_tasks() assert.are.equal(1, #active) end) it('never archives pending tasks regardless of age', function() - store.add({ description = 'Still pending' }) + local s = pending.store() + s:add({ description = 'Still pending' }) pending.archive() - local active = store.active_tasks() + local active = s:active_tasks() assert.are.equal(1, #active) assert.are.equal('pending', active[1].status) end) it('removes deleted tasks past the cutoff', function() - local t = store.add({ description = 'Old deleted task' }) - store.update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' }) + local s = pending.store() + local t = s:add({ description = 'Old deleted task' }) + s:update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' }) pending.archive() - local all = store.tasks() + local all = s:tasks() assert.are.equal(0, #all) end) it('keeps deleted tasks within the cutoff', function() + local s = pending.store() local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) - local t = store.add({ description = 'Recent deleted' }) - store.update(t.id, { status = 'deleted', ['end'] = recent_end }) + local t = s:add({ description = 'Recent deleted' }) + s:update(t.id, { status = 'deleted', ['end'] = recent_end }) pending.archive() - local all = store.tasks() + local all = s:tasks() assert.are.equal(1, #all) end) it('reports the correct count in vim.notify', function() + local s = pending.store() local messages = {} local orig_notify = vim.notify vim.notify = function(msg, ...) @@ -89,11 +98,11 @@ describe('archive', function() return orig_notify(msg, ...) end - local t1 = store.add({ description = 'Old 1' }) - local t2 = store.add({ description = 'Old 2' }) - store.add({ description = 'Keep' }) - store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) - store.update(t2.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + local t1 = s:add({ description = 'Old 1' }) + local t2 = s:add({ description = 'Old 2' }) + s:add({ description = 'Keep' }) + s:update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + s:update(t2.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) pending.archive() @@ -110,16 +119,17 @@ describe('archive', function() end) it('leaves only kept tasks in store.active_tasks after archive', function() - local t1 = store.add({ description = 'Old done' }) - store.add({ description = 'Keep pending' }) + local s = pending.store() + local t1 = s:add({ description = 'Old done' }) + s:add({ description = 'Keep pending' }) local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) - local t3 = store.add({ description = 'Keep recent done' }) - store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) - store.update(t3.id, { status = 'done', ['end'] = recent_end }) + local t3 = s:add({ description = 'Keep recent done' }) + s:update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + s:update(t3.id, { status = 'done', ['end'] = recent_end }) pending.archive() - local active = store.active_tasks() + local active = s:active_tasks() assert.are.equal(2, #active) local descs = {} for _, task in ipairs(active) do @@ -130,11 +140,11 @@ describe('archive', function() end) it('persists archived tasks to disk after unload/reload', function() - local t = store.add({ description = 'Archived task' }) - store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + local s = pending.store() + local t = s:add({ description = 'Archived task' }) + s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) pending.archive() - store.unload() - store.load() - assert.are.equal(0, #store.active_tasks()) + s:load() + assert.are.equal(0, #s:active_tasks()) end) end) diff --git a/spec/complete_spec.lua b/spec/complete_spec.lua new file mode 100644 index 0000000..98547e8 --- /dev/null +++ b/spec/complete_spec.lua @@ -0,0 +1,173 @@ +require('spec.helpers') + +local buffer = require('pending.buffer') +local config = require('pending.config') +local store = require('pending.store') + +describe('complete', function() + local tmpdir + local s + local complete = require('pending.complete') + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + config.reset() + s = store.new(tmpdir .. '/tasks.json') + s:load() + buffer.set_store(s) + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + config.reset() + buffer.set_store(nil) + end) + + describe('findstart', function() + it('returns column after colon for cat: prefix', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task cat:Wo' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 16 }) + local result = complete.omnifunc(1, '') + assert.are.equal(15, result) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('returns column after colon for due: prefix', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task due:to' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 16 }) + local result = complete.omnifunc(1, '') + assert.are.equal(15, result) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('returns column after colon for rec: prefix', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task rec:we' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 16 }) + local result = complete.omnifunc(1, '') + assert.are.equal(15, result) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('returns -1 for non-token position', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] some task ' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 14 }) + local result = complete.omnifunc(1, '') + assert.are.equal(-1, result) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + end) + + describe('completions', function() + it('returns existing categories for cat:', function() + s:add({ description = 'A', category = 'Work' }) + s:add({ description = 'B', category = 'Home' }) + s:add({ description = 'C', category = 'Work' }) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task cat: x' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 15 }) + complete.omnifunc(1, '') + local result = complete.omnifunc(0, '') + local words = {} + for _, item in ipairs(result) do + table.insert(words, item.word) + end + assert.is_true(vim.tbl_contains(words, 'Work')) + assert.is_true(vim.tbl_contains(words, 'Home')) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('filters categories by base', function() + s:add({ description = 'A', category = 'Work' }) + s:add({ description = 'B', category = 'Home' }) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task cat:W' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 15 }) + complete.omnifunc(1, '') + local result = complete.omnifunc(0, 'W') + assert.are.equal(1, #result) + assert.are.equal('Work', result[1].word) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('returns named dates for due:', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task due: x' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 15 }) + complete.omnifunc(1, '') + local result = complete.omnifunc(0, '') + assert.is_true(#result > 0) + local words = {} + for _, item in ipairs(result) do + table.insert(words, item.word) + end + assert.is_true(vim.tbl_contains(words, 'today')) + assert.is_true(vim.tbl_contains(words, 'tomorrow')) + assert.is_true(vim.tbl_contains(words, 'eom')) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('filters dates by base prefix', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task due:to' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 16 }) + complete.omnifunc(1, '') + local result = complete.omnifunc(0, 'to') + local words = {} + for _, item in ipairs(result) do + table.insert(words, item.word) + end + assert.is_true(vim.tbl_contains(words, 'today')) + assert.is_true(vim.tbl_contains(words, 'tomorrow')) + assert.is_false(vim.tbl_contains(words, 'eom')) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('returns recurrence shorthands for rec:', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task rec: x' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 15 }) + complete.omnifunc(1, '') + local result = complete.omnifunc(0, '') + assert.is_true(#result > 0) + local words = {} + for _, item in ipairs(result) do + table.insert(words, item.word) + end + assert.is_true(vim.tbl_contains(words, 'daily')) + assert.is_true(vim.tbl_contains(words, 'weekly')) + assert.is_true(vim.tbl_contains(words, '!weekly')) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('filters recurrence by base prefix', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task rec:we' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 16 }) + complete.omnifunc(1, '') + local result = complete.omnifunc(0, 'we') + local words = {} + for _, item in ipairs(result) do + table.insert(words, item.word) + end + assert.is_true(vim.tbl_contains(words, 'weekly')) + assert.is_true(vim.tbl_contains(words, 'weekdays')) + assert.is_false(vim.tbl_contains(words, 'daily')) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + end) +end) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index fda2165..01d8aac 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -1,35 +1,31 @@ require('spec.helpers') -local config = require('pending.config') local store = require('pending.store') describe('diff', function() local tmpdir + local s local diff = require('pending.diff') before_each(function() tmpdir = vim.fn.tempname() vim.fn.mkdir(tmpdir, 'p') - vim.g.pending = { data_path = tmpdir .. '/tasks.json' } - config.reset() - store.unload() - store.load() + s = store.new(tmpdir .. '/tasks.json') + s:load() end) after_each(function() vim.fn.delete(tmpdir, 'rf') - vim.g.pending = nil - config.reset() end) describe('parse_buffer', function() it('parses headers and tasks', function() local lines = { - '## School', + '# School', '/1/- [ ] Do homework', '/2/- [!] Read chapter 5', '', - '## Errands', + '# Errands', '/3/- [ ] Buy groceries', } local result = diff.parse_buffer(lines) @@ -48,7 +44,7 @@ describe('diff', function() it('handles new tasks without ids', function() local lines = { - '## Inbox', + '# Inbox', '- [ ] New task here', } local result = diff.parse_buffer(lines) @@ -60,7 +56,7 @@ describe('diff', function() it('inline cat: token overrides header category', function() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Buy milk cat:Work', } local result = diff.parse_buffer(lines) @@ -69,9 +65,28 @@ describe('diff', function() assert.are.equal('Work', result[2].category) end) + it('extracts rec: token from buffer line', function() + local lines = { + '# Inbox', + '/1/- [ ] Take trash out rec:weekly', + } + local result = diff.parse_buffer(lines) + assert.are.equal('weekly', result[2].rec) + end) + + it('extracts rec: with completion mode', function() + local lines = { + '# Inbox', + '/1/- [ ] Water plants rec:!daily', + } + local result = diff.parse_buffer(lines) + assert.are.equal('daily', result[2].rec) + assert.are.equal('completion', result[2].rec_mode) + end) + it('inline due: token is parsed', function() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Buy milk due:2026-03-15', } local result = diff.parse_buffer(lines) @@ -84,139 +99,192 @@ describe('diff', function() describe('apply', function() it('creates new tasks from buffer lines', function() local lines = { - '## Inbox', + '# Inbox', '- [ ] First task', '- [ ] Second task', } - diff.apply(lines) - store.unload() - store.load() - local tasks = store.active_tasks() + diff.apply(lines, s) + s:load() + local tasks = s:active_tasks() assert.are.equal(2, #tasks) assert.are.equal('First task', tasks[1].description) assert.are.equal('Second task', tasks[2].description) end) it('deletes tasks removed from buffer', function() - store.add({ description = 'Keep me' }) - store.add({ description = 'Delete me' }) - store.save() + s:add({ description = 'Keep me' }) + s:add({ description = 'Delete me' }) + s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Keep me', } - diff.apply(lines) - store.unload() - store.load() - local active = store.active_tasks() + diff.apply(lines, s) + s:load() + local active = s:active_tasks() assert.are.equal(1, #active) assert.are.equal('Keep me', active[1].description) - local deleted = store.get(2) + local deleted = s:get(2) assert.are.equal('deleted', deleted.status) end) it('updates modified tasks', function() - store.add({ description = 'Original' }) - store.save() + s:add({ description = 'Original' }) + s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Renamed', } - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) + diff.apply(lines, s) + s:load() + local task = s:get(1) assert.are.equal('Renamed', task.description) end) it('updates modified when description is renamed', function() - local t = store.add({ description = 'Original', category = 'Inbox' }) + local t = s:add({ description = 'Original', category = 'Inbox' }) t.modified = '2020-01-01T00:00:00Z' - store.save() + s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Renamed', } - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) + diff.apply(lines, s) + s:load() + local task = s:get(1) assert.are.equal('Renamed', task.description) assert.is_not.equal('2020-01-01T00:00:00Z', task.modified) end) it('handles duplicate ids as copies', function() - store.add({ description = 'Original' }) - store.save() + s:add({ description = 'Original' }) + s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Original', '/1/- [ ] Copy of original', } - diff.apply(lines) - store.unload() - store.load() - local tasks = store.active_tasks() + diff.apply(lines, s) + s:load() + local tasks = s:active_tasks() assert.are.equal(2, #tasks) end) it('moves tasks between categories', function() - store.add({ description = 'Moving task', category = 'Inbox' }) - store.save() + s:add({ description = 'Moving task', category = 'Inbox' }) + s:save() local lines = { - '## Work', + '# Work', '/1/- [ ] Moving task', } - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) + diff.apply(lines, s) + s:load() + local task = s:get(1) assert.are.equal('Work', task.category) end) it('does not update modified when task is unchanged', function() - store.add({ description = 'Stable task', category = 'Inbox' }) - store.save() + s:add({ description = 'Stable task', category = 'Inbox' }) + s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Stable task', } - diff.apply(lines) - store.unload() - store.load() - local modified_after_first = store.get(1).modified - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) + diff.apply(lines, s) + s:load() + local modified_after_first = s:get(1).modified + diff.apply(lines, s) + s:load() + local task = s:get(1) assert.are.equal(modified_after_first, task.modified) end) - it('clears due when removed from buffer line', function() - store.add({ description = 'Pay bill', due = '2026-03-15' }) - store.save() + it('preserves due when not present in buffer line', function() + s:add({ description = 'Pay bill', due = '2026-03-15' }) + s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Pay bill', } - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) - assert.is_nil(task.due) + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal('2026-03-15', task.due) + end) + + it('updates due when inline token is present', function() + s:add({ description = 'Pay bill', due = '2026-03-15' }) + s:save() + local lines = { + '# Inbox', + '/1/- [ ] Pay bill due:2026-04-01', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal('2026-04-01', task.due) + end) + + it('stores recur field on new tasks from buffer', function() + local lines = { + '# Inbox', + '- [ ] Take out trash rec:weekly', + } + diff.apply(lines, s) + s:load() + local tasks = s:active_tasks() + assert.are.equal(1, #tasks) + assert.are.equal('weekly', tasks[1].recur) + end) + + it('updates recur field when changed inline', function() + s:add({ description = 'Task', recur = 'daily' }) + s:save() + local lines = { + '# Todo', + '/1/- [ ] Task rec:weekly', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal('weekly', task.recur) + end) + + it('preserves recur when not present in buffer line', function() + s:add({ description = 'Task', recur = 'daily' }) + s:save() + local lines = { + '# Todo', + '/1/- [ ] Task', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal('daily', task.recur) + end) + + it('parses rec: with completion mode prefix', function() + local lines = { + '# Inbox', + '- [ ] Water plants rec:!weekly', + } + diff.apply(lines, s) + s:load() + local tasks = s:active_tasks() + assert.are.equal('weekly', tasks[1].recur) + assert.are.equal('completion', tasks[1].recur_mode) end) it('clears priority when [N] is removed from buffer line', function() - store.add({ description = 'Task name', priority = 1 }) - store.save() + s:add({ description = 'Task name', priority = 1 }) + s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Task name', } - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) + diff.apply(lines, s) + s:load() + local task = s:get(1) assert.are.equal(0, task.priority) end) end) diff --git a/spec/edit_spec.lua b/spec/edit_spec.lua new file mode 100644 index 0000000..08ef9e0 --- /dev/null +++ b/spec/edit_spec.lua @@ -0,0 +1,329 @@ +require('spec.helpers') + +local config = require('pending.config') + +describe('edit', function() + local tmpdir + local pending + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + config.reset() + package.loaded['pending'] = nil + pending = require('pending') + pending.store():load() + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + vim.g.pending = nil + config.reset() + package.loaded['pending'] = nil + end) + + it('sets due date with resolve_date vocabulary', function() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() + pending.edit(tostring(t.id), 'due:tomorrow') + local updated = s:get(t.id) + local today = os.date('*t') --[[@as osdate]] + local expected = + os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) + assert.are.equal(expected, updated.due) + end) + + it('sets due date with literal YYYY-MM-DD', function() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() + pending.edit(tostring(t.id), 'due:2026-06-15') + local updated = s:get(t.id) + assert.are.equal('2026-06-15', updated.due) + end) + + it('sets category', function() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() + pending.edit(tostring(t.id), 'cat:Work') + local updated = s:get(t.id) + assert.are.equal('Work', updated.category) + end) + + it('adds priority', function() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() + pending.edit(tostring(t.id), '+!') + local updated = s:get(t.id) + assert.are.equal(1, updated.priority) + end) + + it('removes priority', function() + local s = pending.store() + local t = s:add({ description = 'Task one', priority = 1 }) + s:save() + pending.edit(tostring(t.id), '-!') + local updated = s:get(t.id) + assert.are.equal(0, updated.priority) + end) + + it('removes due date', function() + local s = pending.store() + local t = s:add({ description = 'Task one', due = '2026-06-15' }) + s:save() + pending.edit(tostring(t.id), '-due') + local updated = s:get(t.id) + assert.is_nil(updated.due) + end) + + it('removes category', function() + local s = pending.store() + local t = s:add({ description = 'Task one', category = 'Work' }) + s:save() + pending.edit(tostring(t.id), '-cat') + local updated = s:get(t.id) + assert.is_nil(updated.category) + end) + + it('sets recurrence', function() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() + pending.edit(tostring(t.id), 'rec:weekly') + local updated = s:get(t.id) + assert.are.equal('weekly', updated.recur) + assert.is_nil(updated.recur_mode) + end) + + it('sets completion-based recurrence', function() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() + pending.edit(tostring(t.id), 'rec:!daily') + local updated = s:get(t.id) + assert.are.equal('daily', updated.recur) + assert.are.equal('completion', updated.recur_mode) + end) + + it('removes recurrence', function() + local s = pending.store() + local t = s:add({ description = 'Task one', recur = 'weekly', recur_mode = 'scheduled' }) + s:save() + pending.edit(tostring(t.id), '-rec') + local updated = s:get(t.id) + assert.is_nil(updated.recur) + assert.is_nil(updated.recur_mode) + end) + + it('applies multiple operations at once', function() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() + pending.edit(tostring(t.id), 'due:today cat:Errands +!') + local updated = s:get(t.id) + assert.are.equal(os.date('%Y-%m-%d'), updated.due) + assert.are.equal('Errands', updated.category) + assert.are.equal(1, updated.priority) + end) + + it('pushes to undo stack', function() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() + local stack_before = #s:undo_stack() + pending.edit(tostring(t.id), 'cat:Work') + assert.are.equal(stack_before + 1, #s:undo_stack()) + end) + + it('persists changes to disk', function() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() + pending.edit(tostring(t.id), 'cat:Work') + s:load() + local updated = s:get(t.id) + assert.are.equal('Work', updated.category) + end) + + it('errors on unknown task ID', function() + local s = pending.store() + s:add({ description = 'Task one' }) + s:save() + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, level) + table.insert(messages, { msg = msg, level = level }) + end + pending.edit('999', 'cat:Work') + vim.notify = orig_notify + assert.are.equal(1, #messages) + assert.truthy(messages[1].msg:find('No task with ID 999')) + assert.are.equal(vim.log.levels.ERROR, messages[1].level) + end) + + it('errors on invalid date', function() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, level) + table.insert(messages, { msg = msg, level = level }) + end + pending.edit(tostring(t.id), 'due:notadate') + vim.notify = orig_notify + assert.are.equal(1, #messages) + assert.truthy(messages[1].msg:find('Invalid date')) + assert.are.equal(vim.log.levels.ERROR, messages[1].level) + end) + + it('errors on unknown operation token', function() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, level) + table.insert(messages, { msg = msg, level = level }) + end + pending.edit(tostring(t.id), 'bogus') + vim.notify = orig_notify + assert.are.equal(1, #messages) + assert.truthy(messages[1].msg:find('Unknown operation')) + assert.are.equal(vim.log.levels.ERROR, messages[1].level) + end) + + it('errors on invalid recurrence pattern', function() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, level) + table.insert(messages, { msg = msg, level = level }) + end + pending.edit(tostring(t.id), 'rec:nope') + vim.notify = orig_notify + assert.are.equal(1, #messages) + assert.truthy(messages[1].msg:find('Invalid recurrence')) + assert.are.equal(vim.log.levels.ERROR, messages[1].level) + end) + + it('errors when no operations given', function() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, level) + table.insert(messages, { msg = msg, level = level }) + end + pending.edit(tostring(t.id), '') + vim.notify = orig_notify + assert.are.equal(1, #messages) + assert.truthy(messages[1].msg:find('Usage')) + assert.are.equal(vim.log.levels.ERROR, messages[1].level) + end) + + it('errors when no id given', function() + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, level) + table.insert(messages, { msg = msg, level = level }) + end + pending.edit('', '') + vim.notify = orig_notify + assert.are.equal(1, #messages) + assert.truthy(messages[1].msg:find('Usage')) + assert.are.equal(vim.log.levels.ERROR, messages[1].level) + end) + + it('errors on non-numeric id', function() + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, level) + table.insert(messages, { msg = msg, level = level }) + end + pending.edit('abc', 'cat:Work') + vim.notify = orig_notify + assert.are.equal(1, #messages) + assert.truthy(messages[1].msg:find('Invalid task ID')) + assert.are.equal(vim.log.levels.ERROR, messages[1].level) + end) + + it('shows feedback message on success', function() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, level) + table.insert(messages, { msg = msg, level = level }) + end + pending.edit(tostring(t.id), 'cat:Work') + vim.notify = orig_notify + assert.are.equal(1, #messages) + assert.truthy(messages[1].msg:find('Task #' .. t.id .. ' updated')) + assert.truthy(messages[1].msg:find('category set to Work')) + end) + + it('respects custom date_syntax', function() + vim.g.pending = { data_path = tmpdir .. '/tasks.json', date_syntax = 'by' } + config.reset() + package.loaded['pending'] = nil + pending = require('pending') + local s = pending.store() + s:load() + local t = s:add({ description = 'Task one' }) + s:save() + pending.edit(tostring(t.id), 'by:tomorrow') + local updated = s:get(t.id) + local today = os.date('*t') --[[@as osdate]] + local expected = + os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) + assert.are.equal(expected, updated.due) + end) + + it('respects custom recur_syntax', function() + vim.g.pending = { data_path = tmpdir .. '/tasks.json', recur_syntax = 'repeat' } + config.reset() + package.loaded['pending'] = nil + pending = require('pending') + local s = pending.store() + s:load() + local t = s:add({ description = 'Task one' }) + s:save() + pending.edit(tostring(t.id), 'repeat:weekly') + local updated = s:get(t.id) + assert.are.equal('weekly', updated.recur) + end) + + it('does not modify store on error', function() + local s = pending.store() + local t = s:add({ description = 'Task one', category = 'Original' }) + s:save() + local orig_notify = vim.notify + vim.notify = function() end + pending.edit(tostring(t.id), 'due:notadate') + vim.notify = orig_notify + local updated = s:get(t.id) + assert.are.equal('Original', updated.category) + assert.is_nil(updated.due) + end) + + it('sets due date with datetime format', function() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() + pending.edit(tostring(t.id), 'due:tomorrow@14:00') + local updated = s:get(t.id) + local today = os.date('*t') --[[@as osdate]] + local expected = + os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) + assert.are.equal(expected .. 'T14:00', updated.due) + end) +end) diff --git a/spec/filter_spec.lua b/spec/filter_spec.lua new file mode 100644 index 0000000..5e00b60 --- /dev/null +++ b/spec/filter_spec.lua @@ -0,0 +1,292 @@ +require('spec.helpers') + +local config = require('pending.config') +local diff = require('pending.diff') + +describe('filter', function() + local tmpdir + local pending + local buffer + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + config.reset() + package.loaded['pending'] = nil + package.loaded['pending.buffer'] = nil + pending = require('pending') + buffer = require('pending.buffer') + buffer.set_filter({}, {}) + pending.store():load() + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + vim.g.pending = nil + config.reset() + package.loaded['pending'] = nil + package.loaded['pending.buffer'] = nil + end) + + describe('filter predicates', function() + it('cat: hides tasks with non-matching category', function() + local s = pending.store() + s:add({ description = 'Work task', category = 'Work' }) + s:add({ description = 'Home task', category = 'Home' }) + s:save() + pending.filter('cat:Work') + local hidden = buffer.hidden_ids() + local tasks = s:active_tasks() + local work_task = nil + local home_task = nil + for _, t in ipairs(tasks) do + if t.category == 'Work' then + work_task = t + end + if t.category == 'Home' then + home_task = t + end + end + assert.is_not_nil(work_task) + assert.is_not_nil(home_task) + assert.is_nil(hidden[work_task.id]) + assert.is_true(hidden[home_task.id]) + end) + + it('cat: hides tasks with no category (default category)', function() + local s = pending.store() + s:add({ description = 'Work task', category = 'Work' }) + s:add({ description = 'Inbox task' }) + s:save() + pending.filter('cat:Work') + local hidden = buffer.hidden_ids() + local tasks = s:active_tasks() + local inbox_task = nil + for _, t in ipairs(tasks) do + if t.category ~= 'Work' then + inbox_task = t + end + end + assert.is_not_nil(inbox_task) + assert.is_true(hidden[inbox_task.id]) + end) + + it('overdue hides non-overdue tasks', function() + local s = pending.store() + s:add({ description = 'Old task', due = '2020-01-01' }) + s:add({ description = 'Future task', due = '2099-01-01' }) + s:add({ description = 'No due task' }) + s:save() + pending.filter('overdue') + local hidden = buffer.hidden_ids() + local tasks = s:active_tasks() + local overdue_task, future_task, nodue_task + for _, t in ipairs(tasks) do + if t.due == '2020-01-01' then + overdue_task = t + end + if t.due == '2099-01-01' then + future_task = t + end + if not t.due then + nodue_task = t + end + end + assert.is_nil(hidden[overdue_task.id]) + assert.is_true(hidden[future_task.id]) + assert.is_true(hidden[nodue_task.id]) + end) + + it('today hides non-today tasks', function() + local s = pending.store() + local today = os.date('%Y-%m-%d') --[[@as string]] + s:add({ description = 'Today task', due = today }) + s:add({ description = 'Old task', due = '2020-01-01' }) + s:add({ description = 'Future task', due = '2099-01-01' }) + s:save() + pending.filter('today') + local hidden = buffer.hidden_ids() + local tasks = s:active_tasks() + local today_task, old_task, future_task + for _, t in ipairs(tasks) do + if t.due == today then + today_task = t + end + if t.due == '2020-01-01' then + old_task = t + end + if t.due == '2099-01-01' then + future_task = t + end + end + assert.is_nil(hidden[today_task.id]) + assert.is_true(hidden[old_task.id]) + assert.is_true(hidden[future_task.id]) + end) + + it('priority hides non-priority tasks', function() + local s = pending.store() + s:add({ description = 'Important', priority = 1 }) + s:add({ description = 'Normal' }) + s:save() + pending.filter('priority') + local hidden = buffer.hidden_ids() + local tasks = s:active_tasks() + local important_task, normal_task + for _, t in ipairs(tasks) do + if t.priority and t.priority > 0 then + important_task = t + end + if not t.priority or t.priority == 0 then + normal_task = t + end + end + assert.is_nil(hidden[important_task.id]) + assert.is_true(hidden[normal_task.id]) + end) + + it('multi-predicate AND: cat:Work + overdue', function() + local s = pending.store() + s:add({ description = 'Work overdue', category = 'Work', due = '2020-01-01' }) + s:add({ description = 'Work future', category = 'Work', due = '2099-01-01' }) + s:add({ description = 'Home overdue', category = 'Home', due = '2020-01-01' }) + s:save() + pending.filter('cat:Work overdue') + local hidden = buffer.hidden_ids() + local tasks = s:active_tasks() + local work_overdue, work_future, home_overdue + for _, t in ipairs(tasks) do + if t.description == 'Work overdue' then + work_overdue = t + end + if t.description == 'Work future' then + work_future = t + end + if t.description == 'Home overdue' then + home_overdue = t + end + end + assert.is_nil(hidden[work_overdue.id]) + assert.is_true(hidden[work_future.id]) + assert.is_true(hidden[home_overdue.id]) + end) + + it('filter clear removes all predicates and hidden ids', function() + local s = pending.store() + s:add({ description = 'Work task', category = 'Work' }) + s:add({ description = 'Home task', category = 'Home' }) + s:save() + pending.filter('cat:Work') + assert.are.equal(1, #buffer.filter_predicates()) + pending.filter('clear') + assert.are.equal(0, #buffer.filter_predicates()) + assert.are.same({}, buffer.hidden_ids()) + end) + + it('filter empty string clears filter', function() + local s = pending.store() + s:add({ description = 'Work task', category = 'Work' }) + s:save() + pending.filter('cat:Work') + assert.are.equal(1, #buffer.filter_predicates()) + pending.filter('') + assert.are.equal(0, #buffer.filter_predicates()) + end) + + it('filter predicates persist across set_filter calls', function() + local s = pending.store() + s:add({ description = 'Work task', category = 'Work' }) + s:add({ description = 'Home task', category = 'Home' }) + s:save() + pending.filter('cat:Work') + local preds = buffer.filter_predicates() + assert.are.equal(1, #preds) + assert.are.equal('cat:Work', preds[1]) + local hidden = buffer.hidden_ids() + local tasks = s:active_tasks() + local home_task + for _, t in ipairs(tasks) do + if t.category == 'Home' then + home_task = t + end + end + assert.is_true(hidden[home_task.id]) + end) + end) + + describe('diff.apply with hidden_ids', function() + it('does not mark hidden tasks as deleted', function() + local s = pending.store() + s:add({ description = 'Visible task' }) + s:add({ description = 'Hidden task' }) + s:save() + local tasks = s:active_tasks() + local hidden_task + for _, t in ipairs(tasks) do + if t.description == 'Hidden task' then + hidden_task = t + end + end + local hidden_ids = { [hidden_task.id] = true } + local lines = { + '/1/- [ ] Visible task', + } + diff.apply(lines, s, hidden_ids) + s:load() + local hidden = s:get(hidden_task.id) + assert.are.equal('pending', hidden.status) + end) + + it('marks tasks deleted when not hidden and not in buffer', function() + local s = pending.store() + s:add({ description = 'Keep task' }) + s:add({ description = 'Delete task' }) + s:save() + local tasks = s:active_tasks() + local keep_task, delete_task + for _, t in ipairs(tasks) do + if t.description == 'Keep task' then + keep_task = t + end + if t.description == 'Delete task' then + delete_task = t + end + end + local lines = { + '/' .. keep_task.id .. '/- [ ] Keep task', + } + diff.apply(lines, s, {}) + s:load() + local deleted = s:get(delete_task.id) + assert.are.equal('deleted', deleted.status) + end) + + it('strips FILTER: line before parsing', function() + local s = pending.store() + s:add({ description = 'My task' }) + s:save() + local tasks = s:active_tasks() + local task = tasks[1] + local lines = { + 'FILTER: cat:Work', + '/' .. task.id .. '/- [ ] My task', + } + diff.apply(lines, s, {}) + s:load() + local t = s:get(task.id) + assert.are.equal('pending', t.status) + end) + + it('parse_buffer skips FILTER: header line', function() + local lines = { + 'FILTER: overdue', + '/1/- [ ] A task', + } + local result = diff.parse_buffer(lines) + assert.are.equal(1, #result) + assert.are.equal('task', result[1].type) + assert.are.equal('A task', result[1].description) + end) + end) +end) diff --git a/spec/gtasks_spec.lua b/spec/gtasks_spec.lua new file mode 100644 index 0000000..1e0f7ef --- /dev/null +++ b/spec/gtasks_spec.lua @@ -0,0 +1,368 @@ +require('spec.helpers') + +local gtasks = require('pending.sync.gtasks') + +describe('gtasks field conversion', function() + describe('due date helpers', function() + it('converts date-only to RFC 3339', function() + assert.equals('2026-03-15T00:00:00.000Z', gtasks._due_to_rfc3339('2026-03-15')) + end) + + it('converts datetime to RFC 3339 (strips time)', function() + assert.equals('2026-03-15T00:00:00.000Z', gtasks._due_to_rfc3339('2026-03-15T14:30')) + end) + + it('strips RFC 3339 to date-only', function() + assert.equals('2026-03-15', gtasks._rfc3339_to_date('2026-03-15T00:00:00.000Z')) + end) + end) + + describe('build_notes', function() + it('returns nil when no priority or recur', function() + assert.is_nil(gtasks._build_notes({ priority = 0, recur = nil })) + end) + + it('encodes priority', function() + assert.equals('pri:1', gtasks._build_notes({ priority = 1, recur = nil })) + end) + + it('encodes recur', function() + assert.equals('rec:weekly', gtasks._build_notes({ priority = 0, recur = 'weekly' })) + end) + + it('encodes completion-mode recur with ! prefix', function() + assert.equals( + 'rec:!daily', + gtasks._build_notes({ priority = 0, recur = 'daily', recur_mode = 'completion' }) + ) + end) + + it('encodes both priority and recur', function() + assert.equals('pri:1 rec:weekly', gtasks._build_notes({ priority = 1, recur = 'weekly' })) + end) + end) + + describe('parse_notes', function() + it('returns zeros/nils for nil input', function() + local pri, rec, mode = gtasks._parse_notes(nil) + assert.equals(0, pri) + assert.is_nil(rec) + assert.is_nil(mode) + end) + + it('parses priority', function() + local pri = gtasks._parse_notes('pri:1') + assert.equals(1, pri) + end) + + it('parses recur', function() + local _, rec = gtasks._parse_notes('rec:weekly') + assert.equals('weekly', rec) + end) + + it('parses completion-mode recur', function() + local _, rec, mode = gtasks._parse_notes('rec:!daily') + assert.equals('daily', rec) + assert.equals('completion', mode) + end) + + it('parses both priority and recur', function() + local pri, rec = gtasks._parse_notes('pri:1 rec:monthly') + assert.equals(1, pri) + assert.equals('monthly', rec) + end) + + it('round-trips through build_notes', function() + local task = { priority = 1, recur = 'weekly', recur_mode = nil } + local notes = gtasks._build_notes(task) + local pri, rec = gtasks._parse_notes(notes) + assert.equals(1, pri) + assert.equals('weekly', rec) + end) + end) + + describe('task_to_gtask', function() + it('maps description to title', function() + local body = gtasks._task_to_gtask({ + description = 'Buy milk', + status = 'pending', + priority = 0, + }) + assert.equals('Buy milk', body.title) + end) + + it('maps pending status to needsAction', function() + local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 0 }) + assert.equals('needsAction', body.status) + end) + + it('maps done status to completed', function() + local body = gtasks._task_to_gtask({ description = 'x', status = 'done', priority = 0 }) + assert.equals('completed', body.status) + end) + + it('converts due date to RFC 3339', function() + local body = gtasks._task_to_gtask({ + description = 'x', + status = 'pending', + priority = 0, + due = '2026-03-15', + }) + assert.equals('2026-03-15T00:00:00.000Z', body.due) + end) + + it('omits due when nil', function() + local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 0 }) + assert.is_nil(body.due) + end) + + it('includes notes when priority is set', function() + local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 1 }) + assert.equals('pri:1', body.notes) + end) + + it('omits notes when no extra fields', function() + local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 0 }) + assert.is_nil(body.notes) + end) + end) + + describe('gtask_to_fields', function() + it('maps title to description', function() + local fields = gtasks._gtask_to_fields({ title = 'Buy milk', status = 'needsAction' }, 'Work') + assert.equals('Buy milk', fields.description) + end) + + it('maps category from list name', function() + local fields = gtasks._gtask_to_fields({ title = 'x', status = 'needsAction' }, 'Personal') + assert.equals('Personal', fields.category) + end) + + it('maps needsAction to pending', function() + local fields = gtasks._gtask_to_fields({ title = 'x', status = 'needsAction' }, 'Work') + assert.equals('pending', fields.status) + end) + + it('maps completed to done', function() + local fields = gtasks._gtask_to_fields({ title = 'x', status = 'completed' }, 'Work') + assert.equals('done', fields.status) + end) + + it('strips due date to YYYY-MM-DD', function() + local fields = gtasks._gtask_to_fields({ + title = 'x', + status = 'needsAction', + due = '2026-03-15T00:00:00.000Z', + }, 'Work') + assert.equals('2026-03-15', fields.due) + end) + + it('parses priority from notes', function() + local fields = gtasks._gtask_to_fields({ + title = 'x', + status = 'needsAction', + notes = 'pri:1', + }, 'Work') + assert.equals(1, fields.priority) + end) + + it('parses recur from notes', function() + local fields = gtasks._gtask_to_fields({ + title = 'x', + status = 'needsAction', + notes = 'rec:weekly', + }, 'Work') + assert.equals('weekly', fields.recur) + end) + end) +end) + +describe('gtasks push_pass _gtasks_synced_at', function() + local helpers = require('spec.helpers') + local store_mod = require('pending.store') + local oauth = require('pending.sync.oauth') + local s + local orig_curl + + before_each(function() + local dir = helpers.tmpdir() + s = store_mod.new(dir .. '/pending.json') + s:load() + orig_curl = oauth.curl_request + end) + + after_each(function() + oauth.curl_request = orig_curl + end) + + it('sets _gtasks_synced_at after push create', function() + local task = + s:add({ description = 'New task', status = 'pending', category = 'Work', priority = 0 }) + + oauth.curl_request = function(method, url, _headers, _body) + if method == 'POST' and url:find('/tasks$') then + return { id = 'gtask-new-1' }, nil + end + return {}, nil + end + + local now_ts = '2026-03-05T10:00:00Z' + local tasklists = { Work = 'list-1' } + local by_id = {} + gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id) + + assert.is_not_nil(task._extra) + assert.equals('2026-03-05T10:00:00Z', task._extra['_gtasks_synced_at']) + end) + + it('skips update when modified <= _gtasks_synced_at', function() + local task = + s:add({ description = 'Existing task', status = 'pending', category = 'Work', priority = 0 }) + task._extra = { + _gtasks_task_id = 'remote-1', + _gtasks_list_id = 'list-1', + _gtasks_synced_at = '2026-03-05T10:00:00Z', + } + task.modified = '2026-03-05T09:00:00Z' + + local patch_called = false + oauth.curl_request = function(method, _url, _headers, _body) + if method == 'PATCH' then + patch_called = true + end + return {}, nil + end + + local now_ts = '2026-03-05T11:00:00Z' + local tasklists = { Work = 'list-1' } + local by_id = { ['remote-1'] = task } + gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id) + + assert.is_false(patch_called) + end) + + it('pushes update when modified > _gtasks_synced_at', function() + local task = + s:add({ description = 'Changed task', status = 'pending', category = 'Work', priority = 0 }) + task._extra = { + _gtasks_task_id = 'remote-2', + _gtasks_list_id = 'list-1', + _gtasks_synced_at = '2026-03-05T08:00:00Z', + } + task.modified = '2026-03-05T09:00:00Z' + + local patch_called = false + oauth.curl_request = function(method, _url, _headers, _body) + if method == 'PATCH' then + patch_called = true + end + return {}, nil + end + + local now_ts = '2026-03-05T11:00:00Z' + local tasklists = { Work = 'list-1' } + local by_id = { ['remote-2'] = task } + gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id) + + assert.is_true(patch_called) + end) + + it('pushes update when no _gtasks_synced_at (backwards compat)', function() + local task = + s:add({ description = 'Old task', status = 'pending', category = 'Work', priority = 0 }) + task._extra = { + _gtasks_task_id = 'remote-3', + _gtasks_list_id = 'list-1', + } + task.modified = '2026-01-01T00:00:00Z' + + local patch_called = false + oauth.curl_request = function(method, _url, _headers, _body) + if method == 'PATCH' then + patch_called = true + end + return {}, nil + end + + local now_ts = '2026-03-05T11:00:00Z' + local tasklists = { Work = 'list-1' } + local by_id = { ['remote-3'] = task } + gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id) + + assert.is_true(patch_called) + end) +end) + +describe('gtasks detect_remote_deletions', function() + local helpers = require('spec.helpers') + local store_mod = require('pending.store') + local s + + before_each(function() + local dir = helpers.tmpdir() + s = store_mod.new(dir .. '/pending.json') + s:load() + end) + + it('clears remote IDs when list was fetched but task ID is absent', function() + local task = + s:add({ description = 'Gone remote', status = 'pending', category = 'Work', priority = 0 }) + task._extra = { + _gtasks_task_id = 'old-remote-id', + _gtasks_list_id = 'list-1', + _gtasks_synced_at = '2026-01-01T00:00:00Z', + } + + local seen = {} + local fetched = { ['list-1'] = true } + local now_ts = '2026-03-05T10:00:00Z' + + local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts) + + assert.equals(1, unlinked) + assert.is_nil(task._extra) + assert.equals('2026-03-05T10:00:00Z', task.modified) + end) + + it('leaves task untouched when its list fetch failed', function() + local task = s:add({ + description = 'Unknown list task', + status = 'pending', + category = 'Work', + priority = 0, + }) + task._extra = { + _gtasks_task_id = 'remote-id', + _gtasks_list_id = 'list-unfetched', + } + + local seen = {} + local fetched = {} + local now_ts = '2026-03-05T10:00:00Z' + + local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts) + + assert.equals(0, unlinked) + assert.is_not_nil(task._extra) + assert.equals('remote-id', task._extra['_gtasks_task_id']) + end) + + it('skips tasks with status == deleted', function() + local task = + s:add({ description = 'Deleted task', status = 'deleted', category = 'Work', priority = 0 }) + task._extra = { + _gtasks_task_id = 'remote-del', + _gtasks_list_id = 'list-1', + } + + local seen = {} + local fetched = { ['list-1'] = true } + local now_ts = '2026-03-05T10:00:00Z' + + local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts) + + assert.equals(0, unlinked) + assert.is_not_nil(task._extra) + assert.equals('remote-del', task._extra['_gtasks_task_id']) + end) +end) diff --git a/spec/icons_spec.lua b/spec/icons_spec.lua new file mode 100644 index 0000000..47b518c --- /dev/null +++ b/spec/icons_spec.lua @@ -0,0 +1,56 @@ +require('spec.helpers') + +local config = require('pending.config') + +describe('icons', function() + before_each(function() + vim.g.pending = nil + config.reset() + end) + + after_each(function() + vim.g.pending = nil + config.reset() + end) + + it('has default icon values', function() + local icons = config.get().icons + assert.equals(' ', icons.pending) + assert.equals('x', icons.done) + assert.equals('!', icons.priority) + assert.equals('.', icons.due) + assert.equals('~', icons.recur) + assert.equals('#', icons.category) + end) + + it('allows overriding individual icons', function() + vim.g.pending = { icons = { pending = '*', done = '+' } } + config.reset() + local icons = config.get().icons + assert.equals('*', icons.pending) + assert.equals('+', icons.done) + assert.equals('!', icons.priority) + assert.equals('#', icons.category) + end) + + it('allows overriding all icons', function() + vim.g.pending = { + icons = { + pending = '-', + done = '+', + priority = '*', + due = '@', + recur = '^', + category = '&', + }, + } + config.reset() + local icons = config.get().icons + assert.equals('-', icons.pending) + assert.equals('+', icons.done) + assert.equals('*', icons.priority) + assert.equals('@', icons.due) + assert.equals('^', icons.recur) + assert.equals('&', icons.category) + end) +end) diff --git a/spec/oauth_spec.lua b/spec/oauth_spec.lua new file mode 100644 index 0000000..a4a6f1d --- /dev/null +++ b/spec/oauth_spec.lua @@ -0,0 +1,235 @@ +require('spec.helpers') + +local config = require('pending.config') +local oauth = require('pending.sync.oauth') + +describe('oauth', function() + local tmpdir + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + config.reset() + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + vim.g.pending = nil + config.reset() + end) + + describe('url_encode', function() + it('leaves alphanumerics unchanged', function() + assert.equals('hello123', oauth.url_encode('hello123')) + end) + + it('encodes spaces', function() + assert.equals('hello%20world', oauth.url_encode('hello world')) + end) + + it('encodes special characters', function() + assert.equals('a%3Db%26c', oauth.url_encode('a=b&c')) + end) + + it('preserves hyphens, dots, underscores, tildes', function() + assert.equals('a-b.c_d~e', oauth.url_encode('a-b.c_d~e')) + end) + end) + + describe('load_json_file', function() + it('returns nil for missing file', function() + assert.is_nil(oauth.load_json_file(tmpdir .. '/nonexistent.json')) + end) + + it('returns nil for empty file', function() + local path = tmpdir .. '/empty.json' + local f = io.open(path, 'w') + f:write('') + f:close() + assert.is_nil(oauth.load_json_file(path)) + end) + + it('returns nil for invalid JSON', function() + local path = tmpdir .. '/bad.json' + local f = io.open(path, 'w') + f:write('not json') + f:close() + assert.is_nil(oauth.load_json_file(path)) + end) + + it('parses valid JSON', function() + local path = tmpdir .. '/good.json' + local f = io.open(path, 'w') + f:write('{"key":"value"}') + f:close() + local data = oauth.load_json_file(path) + assert.equals('value', data.key) + end) + end) + + describe('save_json_file', function() + it('creates parent directories', function() + local path = tmpdir .. '/sub/dir/file.json' + local ok = oauth.save_json_file(path, { test = true }) + assert.is_true(ok) + local data = oauth.load_json_file(path) + assert.is_true(data.test) + end) + + it('sets restrictive permissions', function() + local path = tmpdir .. '/secret.json' + oauth.save_json_file(path, { x = 1 }) + local perms = vim.fn.getfperm(path) + assert.equals('rw-------', perms) + end) + end) + + describe('resolve_credentials', function() + it('uses config fields when set', function() + config.reset() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + sync = { + gtasks = { + client_id = 'config-id', + client_secret = 'config-secret', + }, + }, + } + local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) + local creds = c:resolve_credentials() + assert.equals('config-id', creds.client_id) + assert.equals('config-secret', creds.client_secret) + end) + + it('uses credentials file when config fields absent', function() + local cred_path = tmpdir .. '/creds.json' + oauth.save_json_file(cred_path, { + client_id = 'file-id', + client_secret = 'file-secret', + }) + config.reset() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + sync = { gtasks = { credentials_path = cred_path } }, + } + local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) + local creds = c:resolve_credentials() + assert.equals('file-id', creds.client_id) + assert.equals('file-secret', creds.client_secret) + end) + + it('unwraps installed wrapper format', function() + local cred_path = tmpdir .. '/wrapped.json' + oauth.save_json_file(cred_path, { + installed = { + client_id = 'wrapped-id', + client_secret = 'wrapped-secret', + }, + }) + config.reset() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + sync = { gcal = { credentials_path = cred_path } }, + } + local c = oauth.new({ name = 'gcal', scope = 'x', port = 0, config_key = 'gcal' }) + local creds = c:resolve_credentials() + assert.equals('wrapped-id', creds.client_id) + assert.equals('wrapped-secret', creds.client_secret) + end) + + it('falls back to bundled credentials', function() + config.reset() + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + local orig_load = oauth.load_json_file + oauth.load_json_file = function() + return nil + end + local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) + local creds = c:resolve_credentials() + oauth.load_json_file = orig_load + assert.equals(oauth._BUNDLED_CLIENT_ID, creds.client_id) + assert.equals(oauth._BUNDLED_CLIENT_SECRET, creds.client_secret) + end) + + it('prefers config fields over credentials file', function() + local cred_path = tmpdir .. '/creds2.json' + oauth.save_json_file(cred_path, { + client_id = 'file-id', + client_secret = 'file-secret', + }) + config.reset() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + sync = { + gtasks = { + credentials_path = cred_path, + client_id = 'config-id', + client_secret = 'config-secret', + }, + }, + } + local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) + local creds = c:resolve_credentials() + assert.equals('config-id', creds.client_id) + assert.equals('config-secret', creds.client_secret) + end) + end) + + describe('token_path', function() + it('includes backend name', function() + local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) + assert.truthy(c:token_path():match('gtasks_tokens%.json$')) + end) + + it('differs between backends', function() + local g = oauth.new({ name = 'gcal', scope = 'x', port = 0, config_key = 'gcal' }) + local t = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) + assert.not_equals(g:token_path(), t:token_path()) + end) + end) + + describe('load_tokens / save_tokens', function() + it('round-trips tokens', function() + local c = oauth.new({ name = 'test', scope = 'x', port = 0, config_key = 'gtasks' }) + local path = c:token_path() + local dir = vim.fn.fnamemodify(path, ':h') + vim.fn.mkdir(dir, 'p') + local tokens = { + access_token = 'at', + refresh_token = 'rt', + expires_in = 3600, + obtained_at = 1000, + } + c:save_tokens(tokens) + local loaded = c:load_tokens() + assert.equals('at', loaded.access_token) + assert.equals('rt', loaded.refresh_token) + vim.fn.delete(dir, 'rf') + end) + end) + + describe('auth_headers', function() + it('includes bearer token', function() + local headers = oauth.auth_headers('mytoken') + assert.equals('Authorization: Bearer mytoken', headers[1]) + assert.equals('Content-Type: application/json', headers[2]) + end) + end) + + describe('new', function() + it('creates client with correct fields', function() + local c = oauth.new({ + name = 'test', + scope = 'https://example.com', + port = 12345, + config_key = 'test', + }) + assert.equals('test', c.name) + assert.equals('https://example.com', c.scope) + assert.equals(12345, c.port) + assert.equals('test', c.config_key) + end) + end) +end) diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index ca8047c..0e6ac19 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -154,6 +154,240 @@ describe('parse', function() local result = parse.resolve_date('') assert.is_nil(result) end) + + it("returns yesterday's date for 'yesterday'", function() + local expected = os.date('%Y-%m-%d', os.time() - 86400) + local result = parse.resolve_date('yesterday') + assert.are.equal(expected, result) + end) + + it("returns today's date for 'eod'", function() + local result = parse.resolve_date('eod') + assert.are.equal(os.date('%Y-%m-%d'), result) + end) + + it('returns Monday of current week for sow', function() + local result = parse.resolve_date('sow') + assert.is_not_nil(result) + local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$') + local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) + local wday = os.date('*t', t).wday + assert.are.equal(2, wday) + end) + + it('returns Sunday of current week for eow', function() + local result = parse.resolve_date('eow') + assert.is_not_nil(result) + local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$') + local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) + local wday = os.date('*t', t).wday + assert.are.equal(1, wday) + end) + + it('returns first day of current month for som', function() + local today = os.date('*t') --[[@as osdate]] + local expected = string.format('%04d-%02d-01', today.year, today.month) + local result = parse.resolve_date('som') + assert.are.equal(expected, result) + end) + + it('returns last day of current month for eom', function() + local today = os.date('*t') --[[@as osdate]] + local expected = + os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 })) + local result = parse.resolve_date('eom') + assert.are.equal(expected, result) + end) + + it('returns first day of current quarter for soq', function() + local today = os.date('*t') --[[@as osdate]] + local q = math.ceil(today.month / 3) + local first_month = (q - 1) * 3 + 1 + local expected = string.format('%04d-%02d-01', today.year, first_month) + local result = parse.resolve_date('soq') + assert.are.equal(expected, result) + end) + + it('returns last day of current quarter for eoq', function() + local today = os.date('*t') --[[@as osdate]] + local q = math.ceil(today.month / 3) + local last_month = q * 3 + local expected = + os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 })) + local result = parse.resolve_date('eoq') + assert.are.equal(expected, result) + end) + + it('returns Jan 1 of current year for soy', function() + local today = os.date('*t') --[[@as osdate]] + local expected = string.format('%04d-01-01', today.year) + local result = parse.resolve_date('soy') + assert.are.equal(expected, result) + end) + + it('returns Dec 31 of current year for eoy', function() + local today = os.date('*t') --[[@as osdate]] + local expected = string.format('%04d-12-31', today.year) + local result = parse.resolve_date('eoy') + assert.are.equal(expected, result) + end) + + it('resolves +2w to 14 days from today', function() + local today = os.date('*t') --[[@as osdate]] + local expected = os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + 14 }) + ) + local result = parse.resolve_date('+2w') + assert.are.equal(expected, result) + end) + + it('resolves +3m to 3 months from today', function() + local today = os.date('*t') --[[@as osdate]] + local expected = os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month + 3, day = today.day }) + ) + local result = parse.resolve_date('+3m') + assert.are.equal(expected, result) + end) + + it('resolves -2d to 2 days ago', function() + local today = os.date('*t') --[[@as osdate]] + local expected = os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day - 2 }) + ) + local result = parse.resolve_date('-2d') + assert.are.equal(expected, result) + end) + + it('resolves -1w to 7 days ago', function() + local today = os.date('*t') --[[@as osdate]] + local expected = os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day - 7 }) + ) + local result = parse.resolve_date('-1w') + assert.are.equal(expected, result) + end) + + it("resolves 'later' to someday_date", function() + local result = parse.resolve_date('later') + assert.are.equal('9999-12-30', result) + end) + + it("resolves 'someday' to someday_date", function() + local result = parse.resolve_date('someday') + assert.are.equal('9999-12-30', result) + end) + + it('resolves 15th to next 15th of month', function() + local result = parse.resolve_date('15th') + assert.is_not_nil(result) + local _, _, d = result:match('^(%d+)-(%d+)-(%d+)$') + assert.are.equal('15', d) + end) + + it('resolves 1st to next 1st of month', function() + local result = parse.resolve_date('1st') + assert.is_not_nil(result) + local _, _, d = result:match('^(%d+)-(%d+)-(%d+)$') + assert.are.equal('01', d) + end) + + it('resolves jan to next January 1st', function() + local today = os.date('*t') --[[@as osdate]] + local result = parse.resolve_date('jan') + assert.is_not_nil(result) + local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$') + assert.are.equal('01', m) + assert.are.equal('01', d) + if today.month >= 1 then + assert.are.equal(tostring(today.year + 1), y) + end + end) + + it('resolves dec to next December 1st', function() + local today = os.date('*t') --[[@as osdate]] + local result = parse.resolve_date('dec') + assert.is_not_nil(result) + local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$') + assert.are.equal('12', m) + assert.are.equal('01', d) + if today.month >= 12 then + assert.are.equal(tostring(today.year + 1), y) + else + assert.are.equal(tostring(today.year), y) + end + end) + end) + + describe('resolve_date with time suffix', function() + local today = os.date('*t') --[[@as osdate]] + local tomorrow_str = + os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) --[[@as string]] + + it('resolves bare hour to T09:00', function() + local result = parse.resolve_date('tomorrow@9') + assert.are.equal(tomorrow_str .. 'T09:00', result) + end) + + it('resolves bare military hour to T14:00', function() + local result = parse.resolve_date('tomorrow@14') + assert.are.equal(tomorrow_str .. 'T14:00', result) + end) + + it('resolves H:MM to T09:30', function() + local result = parse.resolve_date('tomorrow@9:30') + assert.are.equal(tomorrow_str .. 'T09:30', result) + end) + + it('resolves HH:MM (existing format) to T09:30', function() + local result = parse.resolve_date('tomorrow@09:30') + assert.are.equal(tomorrow_str .. 'T09:30', result) + end) + + it('resolves 2pm to T14:00', function() + local result = parse.resolve_date('tomorrow@2pm') + assert.are.equal(tomorrow_str .. 'T14:00', result) + end) + + it('resolves 9am to T09:00', function() + local result = parse.resolve_date('tomorrow@9am') + assert.are.equal(tomorrow_str .. 'T09:00', result) + end) + + it('resolves 9:30pm to T21:30', function() + local result = parse.resolve_date('tomorrow@9:30pm') + assert.are.equal(tomorrow_str .. 'T21:30', result) + end) + + it('resolves 12am to T00:00', function() + local result = parse.resolve_date('tomorrow@12am') + assert.are.equal(tomorrow_str .. 'T00:00', result) + end) + + it('resolves 12pm to T12:00', function() + local result = parse.resolve_date('tomorrow@12pm') + assert.are.equal(tomorrow_str .. 'T12:00', result) + end) + + it('rejects hour 24', function() + assert.is_nil(parse.resolve_date('tomorrow@24')) + end) + + it('rejects 13am', function() + assert.is_nil(parse.resolve_date('tomorrow@13am')) + end) + + it('rejects minute 60', function() + assert.is_nil(parse.resolve_date('tomorrow@9:60')) + end) + + it('rejects alphabetic garbage', function() + assert.is_nil(parse.resolve_date('tomorrow@abc')) + end) end) describe('command_add', function() @@ -181,4 +415,73 @@ describe('parse', function() assert.are.equal('2026-03-15', meta.due) 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 new file mode 100644 index 0000000..53b7478 --- /dev/null +++ b/spec/recur_spec.lua @@ -0,0 +1,223 @@ +require('spec.helpers') + +describe('recur', function() + local recur = require('pending.recur') + + describe('parse', function() + it('parses daily', function() + local r = recur.parse('daily') + assert.are.equal('daily', r.freq) + assert.are.equal(1, r.interval) + assert.is_false(r.from_completion) + end) + + it('parses weekdays', function() + local r = recur.parse('weekdays') + assert.are.equal('weekly', r.freq) + assert.are.same({ 'MO', 'TU', 'WE', 'TH', 'FR' }, r.byday) + end) + + it('parses weekly', function() + local r = recur.parse('weekly') + assert.are.equal('weekly', r.freq) + assert.are.equal(1, r.interval) + end) + + it('parses biweekly', function() + local r = recur.parse('biweekly') + assert.are.equal('weekly', r.freq) + assert.are.equal(2, r.interval) + end) + + it('parses monthly', function() + local r = recur.parse('monthly') + assert.are.equal('monthly', r.freq) + assert.are.equal(1, r.interval) + end) + + it('parses quarterly', function() + local r = recur.parse('quarterly') + assert.are.equal('monthly', r.freq) + assert.are.equal(3, r.interval) + end) + + it('parses yearly', function() + local r = recur.parse('yearly') + assert.are.equal('yearly', r.freq) + assert.are.equal(1, r.interval) + end) + + it('parses annual as yearly', function() + local r = recur.parse('annual') + assert.are.equal('yearly', r.freq) + end) + + it('parses 3d as every 3 days', function() + local r = recur.parse('3d') + assert.are.equal('daily', r.freq) + assert.are.equal(3, r.interval) + end) + + it('parses 2w as biweekly', function() + local r = recur.parse('2w') + assert.are.equal('weekly', r.freq) + assert.are.equal(2, r.interval) + end) + + it('parses 6m as every 6 months', function() + local r = recur.parse('6m') + assert.are.equal('monthly', r.freq) + assert.are.equal(6, r.interval) + end) + + it('parses 2y as every 2 years', function() + local r = recur.parse('2y') + assert.are.equal('yearly', r.freq) + assert.are.equal(2, r.interval) + end) + + it('parses ! prefix as completion-based', function() + local r = recur.parse('!weekly') + assert.are.equal('weekly', r.freq) + assert.is_true(r.from_completion) + end) + + it('parses raw RRULE fragment', function() + local r = recur.parse('FREQ=MONTHLY;BYDAY=1MO') + assert.is_not_nil(r) + end) + + it('returns nil for invalid input', function() + assert.is_nil(recur.parse('')) + assert.is_nil(recur.parse('garbage')) + assert.is_nil(recur.parse('0d')) + end) + + it('is case insensitive', function() + local r = recur.parse('Weekly') + assert.are.equal('weekly', r.freq) + end) + end) + + describe('validate', function() + it('returns true for valid specs', function() + assert.is_true(recur.validate('daily')) + assert.is_true(recur.validate('2w')) + assert.is_true(recur.validate('!monthly')) + end) + + it('returns false for invalid specs', function() + assert.is_false(recur.validate('garbage')) + assert.is_false(recur.validate('')) + end) + end) + + describe('next_due', function() + it('advances daily by 1 day', function() + local result = recur.next_due('2099-03-01', 'daily', 'scheduled') + assert.are.equal('2099-03-02', result) + end) + + it('advances weekly by 7 days', function() + local result = recur.next_due('2099-03-01', 'weekly', 'scheduled') + assert.are.equal('2099-03-08', result) + end) + + it('advances monthly and clamps day', function() + local result = recur.next_due('2099-01-31', 'monthly', 'scheduled') + assert.are.equal('2099-02-28', result) + end) + + it('advances yearly and handles leap year', function() + local result = recur.next_due('2096-02-29', 'yearly', 'scheduled') + assert.are.equal('2097-02-28', result) + end) + + it('advances biweekly by 14 days', function() + local result = recur.next_due('2099-03-01', 'biweekly', 'scheduled') + assert.are.equal('2099-03-15', result) + end) + + it('advances quarterly by 3 months', function() + local result = recur.next_due('2099-01-15', 'quarterly', 'scheduled') + assert.are.equal('2099-04-15', result) + end) + + it('scheduled mode skips to future if overdue', function() + local result = recur.next_due('2020-01-01', 'yearly', 'scheduled') + local today = os.date('%Y-%m-%d') --[[@as string]] + assert.is_true(result > today) + end) + + it('completion mode advances from today', function() + local today = os.date('*t') --[[@as osdate]] + local expected = os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month, + day = today.day + 7, + }) + ) + local result = recur.next_due('2020-01-01', 'weekly', 'completion') + assert.are.equal(expected, result) + end) + + it('advances 3d by 3 days', function() + local result = recur.next_due('2099-06-10', '3d', 'scheduled') + assert.are.equal('2099-06-13', result) + end) + end) + + describe('to_rrule', function() + it('converts daily', function() + assert.are.equal('RRULE:FREQ=DAILY', recur.to_rrule('daily')) + end) + + it('converts weekly', function() + assert.are.equal('RRULE:FREQ=WEEKLY', recur.to_rrule('weekly')) + end) + + it('converts biweekly with interval', function() + assert.are.equal('RRULE:FREQ=WEEKLY;INTERVAL=2', recur.to_rrule('biweekly')) + end) + + it('converts weekdays with BYDAY', function() + assert.are.equal('RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', recur.to_rrule('weekdays')) + end) + + it('converts monthly', function() + assert.are.equal('RRULE:FREQ=MONTHLY', recur.to_rrule('monthly')) + end) + + it('converts quarterly with interval', function() + assert.are.equal('RRULE:FREQ=MONTHLY;INTERVAL=3', recur.to_rrule('quarterly')) + end) + + it('converts yearly', function() + assert.are.equal('RRULE:FREQ=YEARLY', recur.to_rrule('yearly')) + end) + + it('converts 2w with interval', function() + assert.are.equal('RRULE:FREQ=WEEKLY;INTERVAL=2', recur.to_rrule('2w')) + end) + + it('prefixes raw RRULE fragment', function() + assert.are.equal('RRULE:FREQ=MONTHLY;BYDAY=1MO', recur.to_rrule('FREQ=MONTHLY;BYDAY=1MO')) + end) + + it('returns empty string for invalid spec', function() + assert.are.equal('', recur.to_rrule('garbage')) + end) + end) + + describe('shorthand_list', function() + it('returns a list of named shorthands', function() + local list = recur.shorthand_list() + assert.is_true(#list >= 8) + assert.is_true(vim.tbl_contains(list, 'daily')) + assert.is_true(vim.tbl_contains(list, 'weekly')) + assert.is_true(vim.tbl_contains(list, 'monthly')) + end) + end) +end) diff --git a/spec/status_spec.lua b/spec/status_spec.lua new file mode 100644 index 0000000..e2d4223 --- /dev/null +++ b/spec/status_spec.lua @@ -0,0 +1,260 @@ +require('spec.helpers') + +local config = require('pending.config') +local parse = require('pending.parse') + +describe('status', function() + local tmpdir + local pending + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + config.reset() + package.loaded['pending'] = nil + pending = require('pending') + pending.store():load() + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + vim.g.pending = nil + config.reset() + package.loaded['pending'] = nil + end) + + describe('counts', function() + it('returns zeroes for empty store', function() + local c = pending.counts() + assert.are.equal(0, c.overdue) + assert.are.equal(0, c.today) + assert.are.equal(0, c.pending) + assert.are.equal(0, c.priority) + assert.is_nil(c.next_due) + end) + + it('counts pending tasks', function() + local s = pending.store() + s:add({ description = 'One' }) + s:add({ description = 'Two' }) + s:save() + pending._recompute_counts() + local c = pending.counts() + assert.are.equal(2, c.pending) + end) + + it('counts priority tasks', function() + local s = pending.store() + s:add({ description = 'Urgent', priority = 1 }) + s:add({ description = 'Normal' }) + s:save() + pending._recompute_counts() + local c = pending.counts() + assert.are.equal(1, c.priority) + end) + + it('counts overdue tasks with date-only', function() + local s = pending.store() + s:add({ description = 'Old task', due = '2020-01-01' }) + s:save() + pending._recompute_counts() + local c = pending.counts() + assert.are.equal(1, c.overdue) + end) + + it('counts overdue tasks with datetime', function() + local s = pending.store() + s:add({ description = 'Old task', due = '2020-01-01T08:00' }) + s:save() + pending._recompute_counts() + local c = pending.counts() + assert.are.equal(1, c.overdue) + end) + + it('counts today tasks', function() + local s = pending.store() + local today = os.date('%Y-%m-%d') --[[@as string]] + s:add({ description = 'Today task', due = today }) + s:save() + pending._recompute_counts() + local c = pending.counts() + assert.are.equal(1, c.today) + assert.are.equal(0, c.overdue) + end) + + it('counts mixed overdue and today', function() + local s = pending.store() + local today = os.date('%Y-%m-%d') --[[@as string]] + s:add({ description = 'Overdue', due = '2020-01-01' }) + s:add({ description = 'Today', due = today }) + s:save() + pending._recompute_counts() + local c = pending.counts() + assert.are.equal(1, c.overdue) + assert.are.equal(1, c.today) + end) + + it('excludes done tasks', function() + local s = pending.store() + local t = s:add({ description = 'Done', due = '2020-01-01' }) + s:update(t.id, { status = 'done' }) + s:save() + pending._recompute_counts() + local c = pending.counts() + assert.are.equal(0, c.overdue) + assert.are.equal(0, c.pending) + end) + + it('excludes deleted tasks', function() + local s = pending.store() + local t = s:add({ description = 'Deleted', due = '2020-01-01' }) + s:delete(t.id) + s:save() + pending._recompute_counts() + local c = pending.counts() + assert.are.equal(0, c.overdue) + assert.are.equal(0, c.pending) + end) + + it('excludes someday sentinel', function() + local s = pending.store() + s:add({ description = 'Someday', due = '9999-12-30' }) + s:save() + pending._recompute_counts() + local c = pending.counts() + assert.are.equal(0, c.overdue) + assert.are.equal(0, c.today) + assert.are.equal(1, c.pending) + end) + + it('picks earliest future date as next_due', function() + local s = pending.store() + local today = os.date('%Y-%m-%d') --[[@as string]] + s:add({ description = 'Soon', due = '2099-06-01' }) + s:add({ description = 'Sooner', due = '2099-03-01' }) + s:add({ description = 'Today', due = today }) + s:save() + pending._recompute_counts() + local c = pending.counts() + assert.are.equal(today, c.next_due) + end) + + it('lazy loads on first counts() call', function() + local path = config.get().data_path + local f = io.open(path, 'w') + f:write(vim.json.encode({ + version = 1, + next_id = 2, + tasks = { + { + id = 1, + description = 'Overdue', + status = 'pending', + due = '2020-01-01', + entry = '2020-01-01T00:00:00Z', + modified = '2020-01-01T00:00:00Z', + }, + }, + })) + f:close() + package.loaded['pending'] = nil + pending = require('pending') + local c = pending.counts() + assert.are.equal(1, c.overdue) + end) + end) + + describe('statusline', function() + it('returns empty string when nothing actionable', function() + local s = pending.store() + s:save() + pending._recompute_counts() + assert.are.equal('', pending.statusline()) + end) + + it('formats overdue only', function() + local s = pending.store() + s:add({ description = 'Old', due = '2020-01-01' }) + s:save() + pending._recompute_counts() + assert.are.equal('1 overdue', pending.statusline()) + end) + + it('formats today only', function() + local s = pending.store() + local today = os.date('%Y-%m-%d') --[[@as string]] + s:add({ description = 'Today', due = today }) + s:save() + pending._recompute_counts() + assert.are.equal('1 today', pending.statusline()) + end) + + it('formats overdue and today', function() + local s = pending.store() + local today = os.date('%Y-%m-%d') --[[@as string]] + s:add({ description = 'Old', due = '2020-01-01' }) + s:add({ description = 'Today', due = today }) + s:save() + pending._recompute_counts() + assert.are.equal('1 overdue, 1 today', pending.statusline()) + end) + end) + + describe('has_due', function() + it('returns false when nothing due', function() + local s = pending.store() + s:add({ description = 'Future', due = '2099-01-01' }) + s:save() + pending._recompute_counts() + assert.is_false(pending.has_due()) + end) + + it('returns true when overdue', function() + local s = pending.store() + s:add({ description = 'Old', due = '2020-01-01' }) + s:save() + pending._recompute_counts() + assert.is_true(pending.has_due()) + end) + + it('returns true when today', function() + local s = pending.store() + local today = os.date('%Y-%m-%d') --[[@as string]] + s:add({ description = 'Now', due = today }) + s:save() + pending._recompute_counts() + assert.is_true(pending.has_due()) + end) + end) + + describe('parse.is_overdue', function() + it('date before today is overdue', function() + assert.is_true(parse.is_overdue('2020-01-01')) + end) + + it('date after today is not overdue', function() + assert.is_false(parse.is_overdue('2099-01-01')) + end) + + it('today date-only is not overdue', function() + local today = os.date('%Y-%m-%d') --[[@as string]] + assert.is_false(parse.is_overdue(today)) + end) + end) + + describe('parse.is_today', function() + it('today date-only is today', function() + local today = os.date('%Y-%m-%d') --[[@as string]] + assert.is_true(parse.is_today(today)) + end) + + it('yesterday is not today', function() + assert.is_false(parse.is_today('2020-01-01')) + end) + + it('tomorrow is not today', function() + assert.is_false(parse.is_today('2099-01-01')) + end) + end) +end) diff --git a/spec/store_spec.lua b/spec/store_spec.lua index bb6266d..827dd21 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -5,31 +5,30 @@ local store = require('pending.store') describe('store', function() local tmpdir + local s before_each(function() tmpdir = vim.fn.tempname() vim.fn.mkdir(tmpdir, 'p') - vim.g.pending = { data_path = tmpdir .. '/tasks.json' } - config.reset() - store.unload() + s = store.new(tmpdir .. '/tasks.json') + s:load() end) after_each(function() vim.fn.delete(tmpdir, 'rf') - vim.g.pending = nil config.reset() end) describe('load', function() it('returns empty data when no file exists', function() - local data = store.load() + local data = s:load() assert.are.equal(1, data.version) assert.are.equal(1, data.next_id) assert.are.same({}, data.tasks) end) it('loads existing data', function() - local path = config.get().data_path + local path = tmpdir .. '/tasks.json' local f = io.open(path, 'w') f:write(vim.json.encode({ version = 1, @@ -52,7 +51,7 @@ describe('store', function() }, })) f:close() - local data = store.load() + local data = s:load() assert.are.equal(3, data.next_id) assert.are.equal(2, #data.tasks) assert.are.equal('Pending one', data.tasks[1].description) @@ -60,7 +59,7 @@ describe('store', function() end) it('preserves unknown fields', function() - local path = config.get().data_path + local path = tmpdir .. '/tasks.json' local f = io.open(path, 'w') f:write(vim.json.encode({ version = 1, @@ -77,8 +76,8 @@ describe('store', function() }, })) f:close() - store.load() - local task = store.get(1) + s:load() + local task = s:get(1) assert.is_not_nil(task._extra) assert.are.equal('hello', task._extra.custom_field) end) @@ -86,9 +85,8 @@ describe('store', function() describe('add', function() it('creates a task with incremented id', function() - store.load() - local t1 = store.add({ description = 'First' }) - local t2 = store.add({ description = 'Second' }) + local t1 = s:add({ description = 'First' }) + local t2 = s:add({ description = 'Second' }) assert.are.equal(1, t1.id) assert.are.equal(2, t2.id) assert.are.equal('pending', t1.status) @@ -96,60 +94,54 @@ describe('store', function() end) it('uses provided category', function() - store.load() - local t = store.add({ description = 'Test', category = 'Work' }) + local t = s:add({ description = 'Test', category = 'Work' }) assert.are.equal('Work', t.category) end) end) describe('update', function() it('updates fields and sets modified', function() - store.load() - local t = store.add({ description = 'Original' }) + local t = s:add({ description = 'Original' }) t.modified = '2025-01-01T00:00:00Z' - store.update(t.id, { description = 'Updated' }) - local updated = store.get(t.id) + s:update(t.id, { description = 'Updated' }) + local updated = s:get(t.id) assert.are.equal('Updated', updated.description) assert.is_not.equal('2025-01-01T00:00:00Z', updated.modified) end) it('sets end timestamp on completion', function() - store.load() - local t = store.add({ description = 'Test' }) + local t = s:add({ description = 'Test' }) assert.is_nil(t['end']) - store.update(t.id, { status = 'done' }) - local updated = store.get(t.id) + s:update(t.id, { status = 'done' }) + local updated = s:get(t.id) assert.is_not_nil(updated['end']) end) it('does not overwrite id or entry', function() - store.load() - local t = store.add({ description = 'Immutable fields' }) + local t = s:add({ description = 'Immutable fields' }) local original_id = t.id local original_entry = t.entry - store.update(t.id, { id = 999, entry = 'x' }) - local updated = store.get(original_id) + s:update(t.id, { id = 999, entry = 'x' }) + local updated = s:get(original_id) assert.are.equal(original_id, updated.id) assert.are.equal(original_entry, updated.entry) end) it('does not overwrite end on second completion', function() - store.load() - local t = store.add({ description = 'Complete twice' }) - store.update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' }) - local first_end = store.get(t.id)['end'] - store.update(t.id, { status = 'done' }) - local task = store.get(t.id) + local t = s:add({ description = 'Complete twice' }) + s:update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' }) + local first_end = s:get(t.id)['end'] + s:update(t.id, { status = 'done' }) + local task = s:get(t.id) assert.are.equal(first_end, task['end']) end) end) describe('delete', function() it('marks task as deleted', function() - store.load() - local t = store.add({ description = 'To delete' }) - store.delete(t.id) - local deleted = store.get(t.id) + local t = s:add({ description = 'To delete' }) + s:delete(t.id) + local deleted = s:get(t.id) assert.are.equal('deleted', deleted.status) assert.is_not_nil(deleted['end']) end) @@ -157,12 +149,10 @@ describe('store', function() describe('save and round-trip', function() it('persists and reloads correctly', function() - store.load() - store.add({ description = 'Persisted', category = 'Work', priority = 1 }) - store.save() - store.unload() - store.load() - local tasks = store.active_tasks() + s:add({ description = 'Persisted', category = 'Work', priority = 1 }) + s:save() + s:load() + local tasks = s:active_tasks() assert.are.equal(1, #tasks) assert.are.equal('Persisted', tasks[1].description) assert.are.equal('Work', tasks[1].category) @@ -170,7 +160,7 @@ describe('store', function() end) it('round-trips unknown fields', function() - local path = config.get().data_path + local path = tmpdir .. '/tasks.json' local f = io.open(path, 'w') f:write(vim.json.encode({ version = 1, @@ -187,22 +177,78 @@ describe('store', function() }, })) f:close() - store.load() - store.save() - store.unload() - store.load() - local task = store.get(1) + s:load() + s:save() + s:load() + local task = s:get(1) assert.are.equal('abc123', task._extra._gcal_event_id) end) end) + describe('recurrence fields', function() + it('persists recur and recur_mode through round-trip', function() + s:add({ description = 'Recurring', recur = 'weekly', recur_mode = 'scheduled' }) + s:save() + s:load() + local task = s:get(1) + assert.are.equal('weekly', task.recur) + assert.are.equal('scheduled', task.recur_mode) + end) + + it('persists recur without recur_mode', function() + s:add({ description = 'Simple recur', recur = 'daily' }) + s:save() + s:load() + local task = s:get(1) + assert.are.equal('daily', task.recur) + assert.is_nil(task.recur_mode) + end) + + it('omits recur fields when not set', function() + s:add({ description = 'No recur' }) + s:save() + s:load() + local task = s:get(1) + assert.is_nil(task.recur) + assert.is_nil(task.recur_mode) + end) + end) + + describe('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() - store.load() - store.add({ description = 'Active' }) - local t2 = store.add({ description = 'To delete' }) - store.delete(t2.id) - local active = store.active_tasks() + s:add({ description = 'Active' }) + local t2 = s:add({ description = 'To delete' }) + s:delete(t2.id) + local active = s:active_tasks() assert.are.equal(1, #active) assert.are.equal('Active', active[1].description) end) @@ -210,27 +256,24 @@ describe('store', function() describe('snapshot', function() it('returns a table of tasks', function() - store.load() - store.add({ description = 'Snap one' }) - store.add({ description = 'Snap two' }) - local snap = store.snapshot() + s:add({ description = 'Snap one' }) + s:add({ description = 'Snap two' }) + local snap = s:snapshot() assert.are.equal(2, #snap) end) it('returns a copy that does not affect the store', function() - store.load() - local t = store.add({ description = 'Original' }) - local snap = store.snapshot() + local t = s:add({ description = 'Original' }) + local snap = s:snapshot() snap[1].description = 'Mutated' - local live = store.get(t.id) + local live = s:get(t.id) assert.are.equal('Original', live.description) end) it('excludes deleted tasks', function() - store.load() - local t = store.add({ description = 'Will be deleted' }) - store.delete(t.id) - local snap = store.snapshot() + local t = s:add({ description = 'Will be deleted' }) + s:delete(t.id) + local snap = s:snapshot() assert.are.equal(0, #snap) end) end) diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua new file mode 100644 index 0000000..7771553 --- /dev/null +++ b/spec/sync_spec.lua @@ -0,0 +1,114 @@ +require('spec.helpers') + +local config = require('pending.config') + +describe('sync', function() + local tmpdir + local pending + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + config.reset() + package.loaded['pending'] = nil + pending = require('pending') + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + vim.g.pending = nil + config.reset() + package.loaded['pending'] = nil + end) + + describe('dispatch', function() + it('errors on unknown subcommand', function() + local msg + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.ERROR then + msg = m + end + end + pending.command('notreal') + vim.notify = orig + assert.are.equal('[pending.nvim]: Unknown subcommand: notreal', msg) + end) + + it('errors on unknown action for valid backend', function() + local msg + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.ERROR then + msg = m + end + end + pending.command('gcal notreal') + vim.notify = orig + assert.are.equal("[pending.nvim]: gcal: No 'notreal' action.", msg) + end) + + it('lists actions when action is omitted', function() + local msg = nil + local orig = vim.notify + vim.notify = function(m) + msg = m + end + pending.command('gcal') + vim.notify = orig + assert.is_not_nil(msg) + assert.is_truthy(msg:find('push')) + end) + + it('routes explicit push action', function() + local called = false + local gcal = require('pending.sync.gcal') + local orig_push = gcal.push + gcal.push = function() + called = true + end + pending.command('gcal push') + gcal.push = orig_push + assert.is_true(called) + end) + + it('routes auth command', function() + local called = false + local orig_auth = pending.auth + pending.auth = function() + called = true + end + pending.command('auth') + pending.auth = orig_auth + assert.is_true(called) + end) + end) + + it('works with sync.gcal config', function() + config.reset() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + sync = { gcal = { client_id = 'test-id' } }, + } + local cfg = config.get() + assert.are.equal('test-id', cfg.sync.gcal.client_id) + end) + + describe('gcal module', function() + it('has name field', function() + local gcal = require('pending.sync.gcal') + assert.are.equal('gcal', gcal.name) + end) + + it('has push function', function() + local gcal = require('pending.sync.gcal') + assert.are.equal('function', type(gcal.push)) + end) + + it('has health function', function() + local gcal = require('pending.sync.gcal') + assert.are.equal('function', type(gcal.health)) + end) + end) +end) diff --git a/spec/textobj_spec.lua b/spec/textobj_spec.lua new file mode 100644 index 0000000..1253f58 --- /dev/null +++ b/spec/textobj_spec.lua @@ -0,0 +1,194 @@ +require('spec.helpers') + +local config = require('pending.config') + +describe('textobj', function() + local textobj = require('pending.textobj') + + before_each(function() + vim.g.pending = nil + config.reset() + end) + + after_each(function() + vim.g.pending = nil + config.reset() + end) + + describe('inner_task_range', function() + it('returns description range for task with id prefix', function() + local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries') + assert.are.equal(10, s) + assert.are.equal(22, e) + end) + + it('returns description range for task without id prefix', function() + local s, e = textobj.inner_task_range('- [ ] Buy groceries') + assert.are.equal(7, s) + assert.are.equal(19, e) + end) + + it('excludes trailing due: token', function() + local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries due:2026-03-15') + assert.are.equal(10, s) + assert.are.equal(22, e) + end) + + it('excludes trailing cat: token', function() + local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries cat:Errands') + assert.are.equal(10, s) + assert.are.equal(22, e) + end) + + it('excludes trailing rec: token', function() + local s, e = textobj.inner_task_range('/1/- [ ] Take out trash rec:weekly') + assert.are.equal(10, s) + assert.are.equal(23, e) + end) + + it('excludes multiple trailing metadata tokens', function() + local s, e = + textobj.inner_task_range('/1/- [ ] Buy milk due:2026-03-15 cat:Errands rec:weekly') + assert.are.equal(10, s) + assert.are.equal(17, e) + end) + + it('handles priority checkbox', function() + local s, e = textobj.inner_task_range('/1/- [!] Important task') + assert.are.equal(10, s) + assert.are.equal(23, e) + end) + + it('handles done checkbox', function() + local s, e = textobj.inner_task_range('/1/- [x] Finished task') + assert.are.equal(10, s) + assert.are.equal(22, e) + end) + + it('handles multi-digit task ids', function() + local s, e = textobj.inner_task_range('/123/- [ ] Some task') + assert.are.equal(12, s) + assert.are.equal(20, e) + end) + + it('does not strip non-metadata tokens', function() + local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries for dinner') + assert.are.equal(10, s) + assert.are.equal(33, e) + end) + + it('stops stripping at first non-metadata token from right', function() + local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries for dinner due:2026-03-15') + assert.are.equal(10, s) + assert.are.equal(33, e) + end) + + it('respects custom date_syntax', function() + vim.g.pending = { date_syntax = 'by' } + config.reset() + local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries by:2026-03-15') + assert.are.equal(10, s) + assert.are.equal(22, e) + end) + + it('respects custom recur_syntax', function() + vim.g.pending = { recur_syntax = 'repeat' } + config.reset() + local s, e = textobj.inner_task_range('/1/- [ ] Take trash repeat:weekly') + assert.are.equal(10, s) + assert.are.equal(19, e) + end) + + it('handles task with only metadata after description', function() + local s, e = textobj.inner_task_range('/1/- [ ] X due:tomorrow') + assert.are.equal(10, s) + assert.are.equal(10, e) + end) + end) + + describe('category_bounds', function() + it('returns header and last row for single category', function() + ---@type pending.LineMeta[] + local meta = { + { type = 'header', category = 'Work' }, + { type = 'task', id = 1 }, + { type = 'task', id = 2 }, + } + local h, l = textobj.category_bounds(2, meta) + assert.are.equal(1, h) + assert.are.equal(3, l) + end) + + it('returns bounds for first category with trailing blank', function() + ---@type pending.LineMeta[] + local meta = { + { type = 'header', category = 'Work' }, + { type = 'task', id = 1 }, + { type = 'blank' }, + { type = 'header', category = 'Personal' }, + { type = 'task', id = 2 }, + } + local h, l = textobj.category_bounds(2, meta) + assert.are.equal(1, h) + assert.are.equal(3, l) + end) + + it('returns bounds for second category', function() + ---@type pending.LineMeta[] + local meta = { + { type = 'header', category = 'Work' }, + { type = 'task', id = 1 }, + { type = 'blank' }, + { type = 'header', category = 'Personal' }, + { type = 'task', id = 2 }, + { type = 'task', id = 3 }, + } + local h, l = textobj.category_bounds(5, meta) + assert.are.equal(4, h) + assert.are.equal(6, l) + end) + + it('returns bounds when cursor is on header', function() + ---@type pending.LineMeta[] + local meta = { + { type = 'header', category = 'Work' }, + { type = 'task', id = 1 }, + } + local h, l = textobj.category_bounds(1, meta) + assert.are.equal(1, h) + assert.are.equal(2, l) + end) + + it('returns nil for blank line with no preceding header', function() + ---@type pending.LineMeta[] + local meta = { + { type = 'blank' }, + { type = 'header', category = 'Work' }, + { type = 'task', id = 1 }, + } + local h, l = textobj.category_bounds(1, meta) + assert.is_nil(h) + assert.is_nil(l) + end) + + it('returns nil for empty meta', function() + local h, l = textobj.category_bounds(1, {}) + assert.is_nil(h) + assert.is_nil(l) + end) + + it('includes blank between header and next header in bounds', function() + ---@type pending.LineMeta[] + local meta = { + { type = 'header', category = 'Work' }, + { type = 'task', id = 1 }, + { type = 'blank' }, + { type = 'header', category = 'Home' }, + { type = 'task', id = 2 }, + } + local h, l = textobj.category_bounds(1, meta) + assert.are.equal(1, h) + assert.are.equal(3, l) + end) + end) +end) diff --git a/spec/views_spec.lua b/spec/views_spec.lua index 4d91e06..b09633f 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -5,39 +5,38 @@ local store = require('pending.store') describe('views', function() local tmpdir + local s local views = require('pending.views') before_each(function() tmpdir = vim.fn.tempname() vim.fn.mkdir(tmpdir, 'p') - vim.g.pending = { data_path = tmpdir .. '/tasks.json' } config.reset() - store.unload() - store.load() + s = store.new(tmpdir .. '/tasks.json') + s:load() end) after_each(function() vim.fn.delete(tmpdir, 'rf') - vim.g.pending = nil config.reset() end) describe('category_view', function() it('groups tasks under their category header', function() - store.add({ description = 'Task A', category = 'Work' }) - store.add({ description = 'Task B', category = 'Work' }) - local lines, meta = views.category_view(store.active_tasks()) - assert.are.equal('## Work', lines[1]) + s:add({ description = 'Task A', category = 'Work' }) + s:add({ description = 'Task B', category = 'Work' }) + local lines, meta = views.category_view(s:active_tasks()) + assert.are.equal('# Work', lines[1]) assert.are.equal('header', meta[1].type) assert.is_true(lines[2]:find('Task A') ~= nil) assert.is_true(lines[3]:find('Task B') ~= nil) end) it('places pending tasks before done tasks within a category', function() - local t1 = store.add({ description = 'Done task', category = 'Work' }) - store.add({ description = 'Pending task', category = 'Work' }) - store.update(t1.id, { status = 'done' }) - local _, meta = views.category_view(store.active_tasks()) + local t1 = s:add({ description = 'Done task', category = 'Work' }) + s:add({ description = 'Pending task', category = 'Work' }) + s:update(t1.id, { status = 'done' }) + local _, meta = views.category_view(s:active_tasks()) local pending_row, done_row for i, m in ipairs(meta) do if m.type == 'task' and m.status == 'pending' then @@ -50,9 +49,9 @@ describe('views', function() end) it('sorts high-priority tasks before normal tasks within pending group', function() - store.add({ description = 'Normal', category = 'Work', priority = 0 }) - store.add({ description = 'High', category = 'Work', priority = 1 }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Normal', category = 'Work', priority = 0 }) + s:add({ description = 'High', category = 'Work', priority = 1 }) + local lines, meta = views.category_view(s:active_tasks()) local high_row, normal_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -68,11 +67,11 @@ describe('views', function() end) it('sorts high-priority tasks before normal tasks within done group', function() - local t1 = store.add({ description = 'Done Normal', category = 'Work', priority = 0 }) - local t2 = store.add({ description = 'Done High', category = 'Work', priority = 1 }) - store.update(t1.id, { status = 'done' }) - store.update(t2.id, { status = 'done' }) - local lines, meta = views.category_view(store.active_tasks()) + local t1 = s:add({ description = 'Done Normal', category = 'Work', priority = 0 }) + local t2 = s:add({ description = 'Done High', category = 'Work', priority = 1 }) + s:update(t1.id, { status = 'done' }) + s:update(t2.id, { status = 'done' }) + local lines, meta = views.category_view(s:active_tasks()) local high_row, normal_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -88,9 +87,9 @@ describe('views', function() end) it('gives each category its own header with blank lines between them', function() - store.add({ description = 'Task A', category = 'Work' }) - store.add({ description = 'Task B', category = 'Personal' }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Task A', category = 'Work' }) + s:add({ description = 'Task B', category = 'Personal' }) + local lines, meta = views.category_view(s:active_tasks()) local headers = {} local blank_found = false for i, m in ipairs(meta) do @@ -105,8 +104,8 @@ describe('views', function() end) it('formats task lines as /ID/ description', function() - store.add({ description = 'My task', category = 'Inbox' }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'My task', category = 'Inbox' }) + local lines, meta = views.category_view(s:active_tasks()) local task_line for i, m in ipairs(meta) do if m.type == 'task' then @@ -117,8 +116,8 @@ describe('views', function() end) it('formats priority task lines as /ID/- [!] description', function() - store.add({ description = 'Important', category = 'Inbox', priority = 1 }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Important', category = 'Inbox', priority = 1 }) + local lines, meta = views.category_view(s:active_tasks()) local task_line for i, m in ipairs(meta) do if m.type == 'task' then @@ -129,15 +128,15 @@ describe('views', function() end) it('sets LineMeta type=header for header lines with correct category', function() - store.add({ description = 'T', category = 'School' }) - local _, meta = views.category_view(store.active_tasks()) + s:add({ description = 'T', category = 'School' }) + local _, meta = views.category_view(s:active_tasks()) assert.are.equal('header', meta[1].type) assert.are.equal('School', meta[1].category) end) it('sets LineMeta type=task with correct id and status', function() - local t = store.add({ description = 'Do something', category = 'Inbox' }) - local _, meta = views.category_view(store.active_tasks()) + local t = s:add({ description = 'Do something', category = 'Inbox' }) + local _, meta = views.category_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' then @@ -150,9 +149,9 @@ describe('views', function() end) it('sets LineMeta type=blank for blank separator lines', function() - store.add({ description = 'A', category = 'Work' }) - store.add({ description = 'B', category = 'Home' }) - local _, meta = views.category_view(store.active_tasks()) + s:add({ description = 'A', category = 'Work' }) + s:add({ description = 'B', category = 'Home' }) + local _, meta = views.category_view(s:active_tasks()) local blank_meta for _, m in ipairs(meta) do if m.type == 'blank' then @@ -166,8 +165,8 @@ describe('views', function() it('marks overdue pending tasks with meta.overdue=true', function() local yesterday = os.date('%Y-%m-%d', os.time() - 86400) - local t = store.add({ description = 'Overdue task', category = 'Inbox', due = yesterday }) - local _, meta = views.category_view(store.active_tasks()) + local t = s:add({ description = 'Overdue task', category = 'Inbox', due = yesterday }) + local _, meta = views.category_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -179,8 +178,8 @@ describe('views', function() it('does not mark future pending tasks as overdue', function() local tomorrow = os.date('%Y-%m-%d', os.time() + 86400) - local t = store.add({ description = 'Future task', category = 'Inbox', due = tomorrow }) - local _, meta = views.category_view(store.active_tasks()) + local t = s:add({ description = 'Future task', category = 'Inbox', due = tomorrow }) + local _, meta = views.category_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -192,9 +191,9 @@ describe('views', function() it('does not mark done tasks with overdue due dates as overdue', function() local yesterday = os.date('%Y-%m-%d', os.time() - 86400) - local t = store.add({ description = 'Done late', category = 'Inbox', due = yesterday }) - store.update(t.id, { status = 'done' }) - local _, meta = views.category_view(store.active_tasks()) + local t = s:add({ description = 'Done late', category = 'Inbox', due = yesterday }) + s:update(t.id, { status = 'done' }) + local _, meta = views.category_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -204,12 +203,36 @@ describe('views', function() assert.is_falsy(task_meta.overdue) end) + it('includes recur in LineMeta for recurring tasks', function() + s:add({ description = 'Recurring', category = 'Inbox', recur = 'weekly' }) + local _, meta = views.category_view(s:active_tasks()) + local task_meta + for _, m in ipairs(meta) do + if m.type == 'task' then + task_meta = m + end + end + assert.are.equal('weekly', task_meta.recur) + end) + + it('has nil recur in LineMeta for non-recurring tasks', function() + s:add({ description = 'Normal', category = 'Inbox' }) + local _, meta = views.category_view(s:active_tasks()) + local task_meta + for _, m in ipairs(meta) do + if m.type == 'task' then + task_meta = m + end + end + assert.is_nil(task_meta.recur) + end) + it('respects category_order when set', function() - vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work', 'Inbox' } } + vim.g.pending = { data_path = tmpdir .. '/tasks.json', view = { category = { order = { 'Work', 'Inbox' } } } } config.reset() - store.add({ description = 'Inbox task', category = 'Inbox' }) - store.add({ description = 'Work task', category = 'Work' }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Inbox task', category = 'Inbox' }) + s:add({ description = 'Work task', category = 'Work' }) + local lines, meta = views.category_view(s:active_tasks()) local first_header, second_header for i, m in ipairs(meta) do if m.type == 'header' then @@ -220,47 +243,47 @@ describe('views', function() end end end - assert.are.equal('## Work', first_header) - assert.are.equal('## Inbox', second_header) + assert.are.equal('# Work', first_header) + assert.are.equal('# Inbox', second_header) end) it('appends categories not in category_order after ordered ones', function() - vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work' } } + vim.g.pending = { data_path = tmpdir .. '/tasks.json', view = { category = { order = { 'Work' } } } } config.reset() - store.add({ description = 'Errand', category = 'Errands' }) - store.add({ description = 'Work task', category = 'Work' }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Errand', category = 'Errands' }) + s:add({ description = 'Work task', category = 'Work' }) + local lines, meta = views.category_view(s:active_tasks()) local headers = {} for i, m in ipairs(meta) do if m.type == 'header' then table.insert(headers, lines[i]) end end - assert.are.equal('## Work', headers[1]) - assert.are.equal('## Errands', headers[2]) + assert.are.equal('# Work', headers[1]) + assert.are.equal('# Errands', headers[2]) end) it('preserves insertion order when category_order is empty', function() - store.add({ description = 'Alpha task', category = 'Alpha' }) - store.add({ description = 'Beta task', category = 'Beta' }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Alpha task', category = 'Alpha' }) + s:add({ description = 'Beta task', category = 'Beta' }) + local lines, meta = views.category_view(s:active_tasks()) local headers = {} for i, m in ipairs(meta) do if m.type == 'header' then table.insert(headers, lines[i]) end end - assert.are.equal('## Alpha', headers[1]) - assert.are.equal('## Beta', headers[2]) + assert.are.equal('# Alpha', headers[1]) + assert.are.equal('# Beta', headers[2]) end) end) describe('priority_view', function() it('places all pending tasks before done tasks', function() - local t1 = store.add({ description = 'Done A', category = 'Work' }) - store.add({ description = 'Pending B', category = 'Work' }) - store.update(t1.id, { status = 'done' }) - local _, meta = views.priority_view(store.active_tasks()) + local t1 = s:add({ description = 'Done A', category = 'Work' }) + s:add({ description = 'Pending B', category = 'Work' }) + s:update(t1.id, { status = 'done' }) + local _, meta = views.priority_view(s:active_tasks()) local last_pending_row, first_done_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -275,9 +298,9 @@ describe('views', function() end) it('sorts pending tasks by priority desc within pending group', function() - store.add({ description = 'Low', category = 'Work', priority = 0 }) - store.add({ description = 'High', category = 'Work', priority = 1 }) - local lines, meta = views.priority_view(store.active_tasks()) + s:add({ description = 'Low', category = 'Work', priority = 0 }) + s:add({ description = 'High', category = 'Work', priority = 1 }) + local lines, meta = views.priority_view(s:active_tasks()) local high_row, low_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -292,9 +315,9 @@ describe('views', function() end) it('sorts pending tasks with due dates before those without', function() - store.add({ description = 'No due', category = 'Work' }) - store.add({ description = 'Has due', category = 'Work', due = '2099-12-31' }) - local lines, meta = views.priority_view(store.active_tasks()) + s:add({ description = 'No due', category = 'Work' }) + s:add({ description = 'Has due', category = 'Work', due = '2099-12-31' }) + local lines, meta = views.priority_view(s:active_tasks()) local due_row, nodue_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -309,9 +332,9 @@ describe('views', function() end) it('sorts pending tasks with earlier due dates before later due dates', function() - store.add({ description = 'Later', category = 'Work', due = '2099-12-31' }) - store.add({ description = 'Earlier', category = 'Work', due = '2050-01-01' }) - local lines, meta = views.priority_view(store.active_tasks()) + s:add({ description = 'Later', category = 'Work', due = '2099-12-31' }) + s:add({ description = 'Earlier', category = 'Work', due = '2050-01-01' }) + local lines, meta = views.priority_view(s:active_tasks()) local earlier_row, later_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -326,15 +349,15 @@ describe('views', function() end) it('formats task lines as /ID/- [ ] description', function() - store.add({ description = 'My task', category = 'Inbox' }) - local lines, _ = views.priority_view(store.active_tasks()) + s:add({ description = 'My task', category = 'Inbox' }) + local lines, _ = views.priority_view(s:active_tasks()) assert.are.equal('/1/- [ ] My task', lines[1]) end) it('sets show_category=true for all task meta entries', function() - store.add({ description = 'T1', category = 'Work' }) - store.add({ description = 'T2', category = 'Personal' }) - local _, meta = views.priority_view(store.active_tasks()) + s:add({ description = 'T1', category = 'Work' }) + s:add({ description = 'T2', category = 'Personal' }) + local _, meta = views.priority_view(s:active_tasks()) for _, m in ipairs(meta) do if m.type == 'task' then assert.is_true(m.show_category == true) @@ -343,9 +366,9 @@ describe('views', function() end) it('sets meta.category correctly for each task', function() - store.add({ description = 'Work task', category = 'Work' }) - store.add({ description = 'Home task', category = 'Home' }) - local lines, meta = views.priority_view(store.active_tasks()) + s:add({ description = 'Work task', category = 'Work' }) + s:add({ description = 'Home task', category = 'Home' }) + local lines, meta = views.priority_view(s:active_tasks()) local categories = {} for i, m in ipairs(meta) do if m.type == 'task' then @@ -362,8 +385,8 @@ describe('views', function() it('marks overdue pending tasks with meta.overdue=true', function() local yesterday = os.date('%Y-%m-%d', os.time() - 86400) - local t = store.add({ description = 'Overdue', category = 'Inbox', due = yesterday }) - local _, meta = views.priority_view(store.active_tasks()) + local t = s:add({ description = 'Overdue', category = 'Inbox', due = yesterday }) + local _, meta = views.priority_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -375,8 +398,8 @@ describe('views', function() it('does not mark future pending tasks as overdue', function() local tomorrow = os.date('%Y-%m-%d', os.time() + 86400) - local t = store.add({ description = 'Future', category = 'Inbox', due = tomorrow }) - local _, meta = views.priority_view(store.active_tasks()) + local t = s:add({ description = 'Future', category = 'Inbox', due = tomorrow }) + local _, meta = views.priority_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -388,9 +411,9 @@ describe('views', function() it('does not mark done tasks with overdue due dates as overdue', function() local yesterday = os.date('%Y-%m-%d', os.time() - 86400) - local t = store.add({ description = 'Done late', category = 'Inbox', due = yesterday }) - store.update(t.id, { status = 'done' }) - local _, meta = views.priority_view(store.active_tasks()) + local t = s:add({ description = 'Done late', category = 'Inbox', due = yesterday }) + s:update(t.id, { status = 'done' }) + local _, meta = views.priority_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -399,5 +422,29 @@ describe('views', function() end assert.is_falsy(task_meta.overdue) end) + + it('includes recur in LineMeta for recurring tasks', function() + s:add({ description = 'Recurring', category = 'Inbox', recur = 'daily' }) + local _, meta = views.priority_view(s:active_tasks()) + local task_meta + for _, m in ipairs(meta) do + if m.type == 'task' then + task_meta = m + end + end + assert.are.equal('daily', task_meta.recur) + end) + + it('has nil recur in LineMeta for non-recurring tasks', function() + s:add({ description = 'Normal', category = 'Inbox' }) + local _, meta = views.priority_view(s:active_tasks()) + local task_meta + for _, m in ipairs(meta) do + if m.type == 'task' then + task_meta = m + end + end + assert.is_nil(task_meta.recur) + end) end) end)