Compare commits
26 commits
d6f3292d7c
...
49038f9308
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49038f9308 | ||
| e816e6fb7e | |||
| 2b75843dab | |||
|
|
f846155ee5 | ||
|
|
f472ff8990 | ||
| 0b0b64fc3d | |||
|
|
c04057dd9f | ||
|
|
7c3ba31c43 | ||
|
|
4a37cb64e4 | ||
| 5ab0aa78a1 | |||
| b2456580b5 | |||
|
|
969dbd299f | ||
|
|
283f93eda1 | ||
|
|
ea59bbae96 | ||
|
|
9593ab7fe8 | ||
|
|
d35f34d8e0 | ||
|
|
c9790ed3bf | ||
|
|
939251f629 | ||
|
|
46b5d52b60 | ||
|
|
1064b7535a | ||
|
|
6f71ab14ad | ||
|
|
ff9f601f68 | ||
|
|
0d62cd9e40 | ||
|
|
343dbb202b | ||
|
|
26b8bb4beb | ||
|
|
1bd2ef914b |
22 changed files with 2509 additions and 406 deletions
158
doc/pending.txt
158
doc/pending.txt
|
|
@ -140,9 +140,9 @@ COMMANDS *pending-commands*
|
||||||
:Pending add Work: standup due:tomorrow rec:weekdays
|
:Pending add Work: standup due:tomorrow rec:weekdays
|
||||||
:Pending add Buy milk due:fri +!!
|
:Pending add Buy milk due:fri +!!
|
||||||
<
|
<
|
||||||
Trailing `+!`, `+!!`, or `+!!!` tokens set the priority level (capped
|
`+!`, `+!!`, or `+!!!` tokens anywhere in the text set the priority
|
||||||
at `max_priority`). If the buffer is currently open it is re-rendered
|
level (capped at `max_priority`). If the buffer is currently open it
|
||||||
after the add.
|
is re-rendered after the add.
|
||||||
|
|
||||||
*:Pending-archive*
|
*:Pending-archive*
|
||||||
:Pending archive [{duration}]
|
:Pending archive [{duration}]
|
||||||
|
|
@ -347,6 +347,8 @@ Default buffer-local keys: ~
|
||||||
`gr` Prompt for a recurrence pattern (`recur`)
|
`gr` Prompt for a recurrence pattern (`recur`)
|
||||||
`gw` Toggle work-in-progress status (`wip`)
|
`gw` Toggle work-in-progress status (`wip`)
|
||||||
`gb` Toggle blocked status (`blocked`)
|
`gb` Toggle blocked status (`blocked`)
|
||||||
|
`g/` Toggle cancelled status (`cancelled`)
|
||||||
|
`ge` Open markdown detail buffer for task notes (`edit_notes`)
|
||||||
`gf` Prompt for filter predicates (`filter`)
|
`gf` Prompt for filter predicates (`filter`)
|
||||||
`<Tab>` Switch between category / queue view (`view`)
|
`<Tab>` Switch between category / queue view (`view`)
|
||||||
`gz` Undo the last `:w` save (`undo`)
|
`gz` Undo the last `:w` save (`undo`)
|
||||||
|
|
@ -354,6 +356,8 @@ Default buffer-local keys: ~
|
||||||
`O` Insert a new task line above (`open_line_above`)
|
`O` Insert a new task line above (`open_line_above`)
|
||||||
`<C-a>` Increment priority (clamped at `max_priority`) (`priority_up`)
|
`<C-a>` Increment priority (clamped at `max_priority`) (`priority_up`)
|
||||||
`<C-x>` Decrement priority (clamped at 0) (`priority_down`)
|
`<C-x>` Decrement priority (clamped at 0) (`priority_down`)
|
||||||
|
`g<C-a>` Increment priority for visual selection (`priority_up_visual`)
|
||||||
|
`g<C-x>` Decrement priority for visual selection (`priority_down_visual`)
|
||||||
`J` Move task down within its category (`move_down`)
|
`J` Move task down within its category (`move_down`)
|
||||||
`K` Move task up within its category (`move_up`)
|
`K` Move task up within its category (`move_up`)
|
||||||
`zc` Fold the current category section (requires `folding`)
|
`zc` Fold the current category section (requires `folding`)
|
||||||
|
|
@ -468,6 +472,12 @@ old keys to `false`: >lua
|
||||||
Toggle blocked status for the task under the cursor.
|
Toggle blocked status for the task under the cursor.
|
||||||
If the task is already `blocked`, reverts to `pending`.
|
If the task is already `blocked`, reverts to `pending`.
|
||||||
|
|
||||||
|
*<Plug>(pending-cancelled)*
|
||||||
|
<Plug>(pending-cancelled)
|
||||||
|
Toggle cancelled status for the task under the cursor.
|
||||||
|
If the task is already `cancelled`, reverts to `pending`.
|
||||||
|
Toggling on a `done` task switches it to `cancelled`.
|
||||||
|
|
||||||
*<Plug>(pending-priority-up)*
|
*<Plug>(pending-priority-up)*
|
||||||
<Plug>(pending-priority-up)
|
<Plug>(pending-priority-up)
|
||||||
Increment the priority level for the task under the cursor, clamped
|
Increment the priority level for the task under the cursor, clamped
|
||||||
|
|
@ -478,6 +488,12 @@ old keys to `false`: >lua
|
||||||
Decrement the priority level for the task under the cursor, clamped
|
Decrement the priority level for the task under the cursor, clamped
|
||||||
at 0. Default key: `<C-x>`.
|
at 0. Default key: `<C-x>`.
|
||||||
|
|
||||||
|
*<Plug>(pending-edit-notes)*
|
||||||
|
<Plug>(pending-edit-notes)
|
||||||
|
Open the markdown detail buffer for the task under the cursor.
|
||||||
|
Shows a read-only metadata header and editable notes below a `---`
|
||||||
|
separator. Press `q` to return to the task list. Default key: `ge`.
|
||||||
|
|
||||||
*<Plug>(pending-open-line)*
|
*<Plug>(pending-open-line)*
|
||||||
<Plug>(pending-open-line)
|
<Plug>(pending-open-line)
|
||||||
Insert a correctly-formatted blank task line below the cursor.
|
Insert a correctly-formatted blank task line below the cursor.
|
||||||
|
|
@ -535,16 +551,41 @@ Category view (default): ~ *pending-view-category*
|
||||||
Tasks are grouped under their category header. Categories appear in the
|
Tasks are grouped under their category header. Categories appear in the
|
||||||
order tasks were added unless `category_order` is set (see
|
order tasks were added unless `category_order` is set (see
|
||||||
|pending-config|). Blank lines separate categories. Within each category,
|
|pending-config|). Blank lines separate categories. Within each category,
|
||||||
tasks are sorted by status (wip → pending → blocked → done), then by
|
tasks are sorted by status (wip → pending → blocked → done → cancelled), then by
|
||||||
priority, then by insertion order. Category sections are foldable with
|
priority, then by insertion order. Category sections are foldable with
|
||||||
`zc` and `zo`.
|
`zc` and `zo`.
|
||||||
|
|
||||||
Queue view: ~ *pending-view-queue*
|
Queue view: ~ *pending-view-queue*
|
||||||
A flat list of all tasks sorted by status (wip → pending → blocked →
|
A flat list of all tasks sorted by a configurable tiebreak chain
|
||||||
done), then by priority, then by due date (tasks without a due date sort
|
(default: status → priority → due → order → id). See
|
||||||
last), then by internal order. Category names are shown as right-aligned virtual
|
`view.queue.sort` in |pending-config| for customization. Status
|
||||||
text alongside the due date virtual text so tasks remain identifiable
|
order: wip → pending → blocked → done → cancelled. Category
|
||||||
across categories. The buffer is named `pending://queue`.
|
names are shown as right-aligned virtual text alongside the due date
|
||||||
|
virtual text so tasks remain identifiable across categories. The
|
||||||
|
buffer is named `pending://queue`.
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
DETAIL BUFFER *pending-detail-buffer*
|
||||||
|
|
||||||
|
Press `ge` (or `keymaps.edit_notes`) on a task to open a markdown detail
|
||||||
|
buffer named `pending://task/<id>`. The buffer replaces the task list in
|
||||||
|
the same split.
|
||||||
|
|
||||||
|
Layout: ~
|
||||||
|
|
||||||
|
Line 1: `# <description>` (task description as heading)
|
||||||
|
Lines 2-3: Read-only metadata (status, priority, category, due,
|
||||||
|
recurrence) rendered as virtual text overlays
|
||||||
|
Line 4: `---` separator
|
||||||
|
Line 5+: Free-form markdown notes (editable)
|
||||||
|
|
||||||
|
The metadata header is not editable — it is rendered via extmarks on
|
||||||
|
empty buffer lines. To change metadata, return to the task list and use
|
||||||
|
the normal keymaps or `:Pending edit`.
|
||||||
|
|
||||||
|
Write (`:w`) saves the notes content (everything below the `---`
|
||||||
|
separator) to the `notes` field in the task store. Press `q` to return
|
||||||
|
to the task list.
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
FILTERS *pending-filters*
|
FILTERS *pending-filters*
|
||||||
|
|
@ -578,6 +619,8 @@ Available predicates: ~
|
||||||
|
|
||||||
`blocked` Show only tasks with status `blocked`.
|
`blocked` Show only tasks with status `blocked`.
|
||||||
|
|
||||||
|
`cancelled` Show only tasks with status `cancelled`.
|
||||||
|
|
||||||
`clear` Special value for |:Pending-filter| — clears the active filter
|
`clear` Special value for |:Pending-filter| — clears the active filter
|
||||||
and shows all tasks.
|
and shows all tasks.
|
||||||
|
|
||||||
|
|
@ -595,8 +638,8 @@ task data.
|
||||||
==============================================================================
|
==============================================================================
|
||||||
INLINE METADATA *pending-metadata*
|
INLINE METADATA *pending-metadata*
|
||||||
|
|
||||||
Metadata tokens may be appended to any task line before saving. Tokens are
|
Metadata tokens may appear anywhere in a task line. On save, tokens are
|
||||||
parsed from the right and consumed until a non-metadata token is reached.
|
extracted from any position and the remaining words form the description.
|
||||||
|
|
||||||
Supported tokens: ~
|
Supported tokens: ~
|
||||||
|
|
||||||
|
|
@ -605,9 +648,10 @@ Supported tokens: ~
|
||||||
`cat:Name` Move the task to the named category on save.
|
`cat:Name` Move the task to the named category on save.
|
||||||
`rec:<pattern>` Set a recurrence rule (see |pending-recurrence|).
|
`rec:<pattern>` Set a recurrence rule (see |pending-recurrence|).
|
||||||
|
|
||||||
The token name for due dates defaults to `due` and is configurable via
|
The token name for categories defaults to `cat` and is configurable via
|
||||||
`date_syntax` in |pending-config|. The token name for recurrence defaults to
|
`category_syntax` in |pending-config|. The token name for due dates defaults
|
||||||
`rec` and is configurable via `recur_syntax`.
|
to `due` and is configurable via `date_syntax`. The token name for recurrence
|
||||||
|
defaults to `rec` and is configurable via `recur_syntax`.
|
||||||
|
|
||||||
Example: >
|
Example: >
|
||||||
|
|
||||||
|
|
@ -619,9 +663,8 @@ 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
|
`2026-03-15` and rendered as right-aligned virtual text, and the task is
|
||||||
placed under the `Errands` category header.
|
placed under the `Errands` category header.
|
||||||
|
|
||||||
Parsing stops at the first token that is not a recognised metadata token.
|
Only the first occurrence of each metadata type is consumed — duplicate
|
||||||
Repeated tokens of the same type also stop parsing — only one `due:`, one
|
tokens are dropped with a warning.
|
||||||
`cat:`, and one `rec:` per task line are consumed.
|
|
||||||
|
|
||||||
Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types.
|
Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types.
|
||||||
In insert mode, type the token prefix and press `<C-x><C-o>` to see
|
In insert mode, type the token prefix and press `<C-x><C-o>` to see
|
||||||
|
|
@ -734,6 +777,7 @@ loads: >lua
|
||||||
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
|
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
|
||||||
default_category = 'Todo',
|
default_category = 'Todo',
|
||||||
date_format = '%b %d',
|
date_format = '%b %d',
|
||||||
|
category_syntax = 'cat',
|
||||||
date_syntax = 'due',
|
date_syntax = 'due',
|
||||||
recur_syntax = 'rec',
|
recur_syntax = 'rec',
|
||||||
someday_date = '9999-12-30',
|
someday_date = '9999-12-30',
|
||||||
|
|
@ -745,7 +789,9 @@ loads: >lua
|
||||||
order = {},
|
order = {},
|
||||||
folding = true,
|
folding = true,
|
||||||
},
|
},
|
||||||
queue = {},
|
queue = {
|
||||||
|
sort = { 'status', 'priority', 'due', 'order', 'id' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
keymaps = {
|
keymaps = {
|
||||||
close = 'q',
|
close = 'q',
|
||||||
|
|
@ -771,6 +817,8 @@ loads: >lua
|
||||||
move_up = 'K',
|
move_up = 'K',
|
||||||
wip = 'gw',
|
wip = 'gw',
|
||||||
blocked = 'gb',
|
blocked = 'gb',
|
||||||
|
cancelled = 'g/',
|
||||||
|
edit_notes = 'ge',
|
||||||
},
|
},
|
||||||
sync = {
|
sync = {
|
||||||
gcal = {},
|
gcal = {},
|
||||||
|
|
@ -817,6 +865,12 @@ Fields: ~
|
||||||
'%m/%d', -- 03/15 (year inferred)
|
'%m/%d', -- 03/15 (year inferred)
|
||||||
}
|
}
|
||||||
<
|
<
|
||||||
|
{category_syntax} (string, default: 'cat')
|
||||||
|
The token name for inline category metadata. Change
|
||||||
|
this to use a different keyword, for example
|
||||||
|
`'category'` to write `category:Work` instead of
|
||||||
|
`cat:Work`.
|
||||||
|
|
||||||
{date_syntax} (string, default: 'due')
|
{date_syntax} (string, default: 'due')
|
||||||
The token name for inline due-date metadata. Change
|
The token name for inline due-date metadata. Change
|
||||||
this to use a different keyword, for example `'by'`
|
this to use a different keyword, for example `'by'`
|
||||||
|
|
@ -871,6 +925,24 @@ Fields: ~
|
||||||
|
|
||||||
{queue} (table) *pending.QueueViewConfig*
|
{queue} (table) *pending.QueueViewConfig*
|
||||||
Queue (priority) view settings.
|
Queue (priority) view settings.
|
||||||
|
{sort} (string[], default:
|
||||||
|
`{ 'status', 'priority', 'due',
|
||||||
|
'order', 'id' }`)
|
||||||
|
Ordered tiebreak chain for the
|
||||||
|
queue view sort. Each element is a
|
||||||
|
sort key; the comparator walks the
|
||||||
|
list and returns on the first
|
||||||
|
non-equal comparison. Valid keys:
|
||||||
|
`status` wip < pending <
|
||||||
|
blocked < done
|
||||||
|
`priority` higher number first
|
||||||
|
`due` sooner first, no-due
|
||||||
|
last
|
||||||
|
`order` ascending
|
||||||
|
`id` ascending
|
||||||
|
`age` alias for `id`
|
||||||
|
Unknown keys are ignored with a
|
||||||
|
warning.
|
||||||
|
|
||||||
Examples: >lua
|
Examples: >lua
|
||||||
vim.g.pending = {
|
vim.g.pending = {
|
||||||
|
|
@ -881,6 +953,10 @@ Fields: ~
|
||||||
order = { 'Work', 'Personal' },
|
order = { 'Work', 'Personal' },
|
||||||
folding = { foldtext = '%c: %n items' },
|
folding = { foldtext = '%c: %n items' },
|
||||||
},
|
},
|
||||||
|
queue = {
|
||||||
|
sort = { 'status', 'due', 'priority',
|
||||||
|
'order', 'id' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
<
|
<
|
||||||
|
|
@ -922,17 +998,21 @@ Fields: ~
|
||||||
See |pending-gcal|, |pending-gtasks|, |pending-s3|.
|
See |pending-gcal|, |pending-gtasks|, |pending-s3|.
|
||||||
|
|
||||||
{icons} (table) *pending.Icons*
|
{icons} (table) *pending.Icons*
|
||||||
Icon characters displayed in the buffer. The
|
Icon characters used for rendering and parsing
|
||||||
{pending}, {done}, {priority}, {wip}, and
|
task checkboxes. The {pending}, {done},
|
||||||
{blocked} characters appear inside brackets
|
{priority}, {wip}, {blocked}, and {cancelled}
|
||||||
(`[icon]`) as an overlay on the checkbox. The
|
characters determine what is written inside
|
||||||
{category} character prefixes both header lines
|
brackets (`[icon]`) in the buffer text and how
|
||||||
and EOL category labels. Fields:
|
status is inferred on `:w`. Each must be a
|
||||||
|
single character. The {category} character
|
||||||
|
prefixes header lines and EOL category labels.
|
||||||
|
Fields:
|
||||||
{pending} Pending task character. Default: ' '
|
{pending} Pending task character. Default: ' '
|
||||||
{done} Done task character. Default: 'x'
|
{done} Done task character. Default: 'x'
|
||||||
{priority} Priority task character. Default: '!'
|
{priority} Priority task character. Default: '!'
|
||||||
{wip} Work-in-progress character. Default: '>'
|
{wip} Work-in-progress character. Default: 'w'
|
||||||
{blocked} Blocked task character. Default: '='
|
{blocked} Blocked task character. Default: 'b'
|
||||||
|
{cancelled} Cancelled task character. Default: '/'
|
||||||
{due} Due date prefix. Default: '.'
|
{due} Due date prefix. Default: '.'
|
||||||
{recur} Recurrence prefix. Default: '~'
|
{recur} Recurrence prefix. Default: '~'
|
||||||
{category} Category prefix. Default: '#'
|
{category} Category prefix. Default: '#'
|
||||||
|
|
@ -989,6 +1069,10 @@ PendingWip Applied to the checkbox icon of work-in-progress tasks.
|
||||||
PendingBlocked Applied to the checkbox icon and text of blocked tasks.
|
PendingBlocked Applied to the checkbox icon and text of blocked tasks.
|
||||||
Default: links to `DiagnosticError`.
|
Default: links to `DiagnosticError`.
|
||||||
|
|
||||||
|
*PendingCancelled*
|
||||||
|
PendingCancelled Applied to the checkbox icon and text of cancelled tasks.
|
||||||
|
Default: links to `NonText`.
|
||||||
|
|
||||||
*PendingPriority*
|
*PendingPriority*
|
||||||
PendingPriority Applied to the checkbox icon of priority 1 tasks.
|
PendingPriority Applied to the checkbox icon of priority 1 tasks.
|
||||||
Default: links to `DiagnosticWarn`.
|
Default: links to `DiagnosticWarn`.
|
||||||
|
|
@ -1500,7 +1584,8 @@ Configuration: ~
|
||||||
>lua
|
>lua
|
||||||
vim.g.pending = {
|
vim.g.pending = {
|
||||||
forge = {
|
forge = {
|
||||||
auto_close = false,
|
close = false,
|
||||||
|
validate = false,
|
||||||
warn_missing_cli = true,
|
warn_missing_cli = true,
|
||||||
github = {
|
github = {
|
||||||
icon = '',
|
icon = '',
|
||||||
|
|
@ -1522,9 +1607,15 @@ Configuration: ~
|
||||||
<
|
<
|
||||||
|
|
||||||
Top-level fields: ~
|
Top-level fields: ~
|
||||||
{auto_close} (boolean, default: false) When true, tasks linked to
|
{close} (boolean, default: false) When true, tasks linked to
|
||||||
closed/merged remote issues are automatically marked
|
closed/merged remote issues are automatically marked
|
||||||
done on buffer open.
|
done on buffer open. Only forges with an explicit
|
||||||
|
per-forge key (e.g. `github = {}`) are checked;
|
||||||
|
unconfigured forges are skipped entirely.
|
||||||
|
{validate} (boolean, default: false) When true, new or changed
|
||||||
|
forge refs are validated on `:w` by fetching metadata.
|
||||||
|
Logs a warning if the ref is not found, auth fails, or
|
||||||
|
the CLI is missing.
|
||||||
{warn_missing_cli} (boolean, default: true) When true, warns once per
|
{warn_missing_cli} (boolean, default: true) When true, warns once per
|
||||||
forge per session if the CLI is missing or fails.
|
forge per session if the CLI is missing or fails.
|
||||||
|
|
||||||
|
|
@ -1550,9 +1641,9 @@ than 5 minutes are re-fetched asynchronously. The buffer renders immediately
|
||||||
with cached data and updates extmarks when the fetch completes.
|
with cached data and updates extmarks when the fetch completes.
|
||||||
|
|
||||||
State pull: ~
|
State pull: ~
|
||||||
Requires `forge.auto_close = true`. After fetching, if the remote issue/PR
|
Requires `forge.close = true`. After fetching, if the remote issue/PR
|
||||||
is closed or merged and the local task is pending/wip/blocked, the task is
|
is closed or merged and the local task is pending/wip/blocked (not cancelled),
|
||||||
automatically marked as done. Disabled by default. One-way: local status
|
the task is automatically marked as done. Disabled by default. One-way: local status
|
||||||
changes do not push back to the forge.
|
changes do not push back to the forge.
|
||||||
|
|
||||||
Highlight groups: ~
|
Highlight groups: ~
|
||||||
|
|
@ -1580,7 +1671,7 @@ Task fields: ~
|
||||||
{id} (integer) Unique, auto-incrementing task identifier.
|
{id} (integer) Unique, auto-incrementing task identifier.
|
||||||
{description} (string) Task text as shown in the buffer.
|
{description} (string) Task text as shown in the buffer.
|
||||||
{status} (string) `'pending'`, `'wip'`, `'blocked'`, `'done'`,
|
{status} (string) `'pending'`, `'wip'`, `'blocked'`, `'done'`,
|
||||||
or `'deleted'`.
|
`'cancelled'`, or `'deleted'`.
|
||||||
{category} (string) Category name. Defaults to `default_category`.
|
{category} (string) Category name. Defaults to `default_category`.
|
||||||
{priority} (integer) Priority level: `0` (none), `1`–`3` (or up to
|
{priority} (integer) Priority level: `0` (none), `1`–`3` (or up to
|
||||||
`max_priority`). Higher values sort first.
|
`max_priority`). Higher values sort first.
|
||||||
|
|
@ -1590,6 +1681,7 @@ Task fields: ~
|
||||||
{entry} (string) ISO 8601 UTC timestamp of creation.
|
{entry} (string) ISO 8601 UTC timestamp of creation.
|
||||||
{modified} (string) ISO 8601 UTC timestamp of last modification.
|
{modified} (string) ISO 8601 UTC timestamp of last modification.
|
||||||
{end} (string) ISO 8601 UTC timestamp of completion or deletion.
|
{end} (string) ISO 8601 UTC timestamp of completion or deletion.
|
||||||
|
{notes} (string) Free-form markdown notes (from detail buffer).
|
||||||
{order} (integer) Relative ordering within a category.
|
{order} (integer) Relative ordering within a category.
|
||||||
|
|
||||||
Any field not in the list above is preserved in `_extra` and written back on
|
Any field not in the list above is preserved in `_extra` and written back on
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ local function apply_inline_row(bufnr, row, m, icons)
|
||||||
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, {
|
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, {
|
||||||
end_col = #line,
|
end_col = #line,
|
||||||
hl_group = 'PendingFilter',
|
hl_group = 'PendingFilter',
|
||||||
|
invalidate = true,
|
||||||
})
|
})
|
||||||
elseif m.type == 'task' then
|
elseif m.type == 'task' then
|
||||||
if m.status == 'done' then
|
if m.status == 'done' then
|
||||||
|
|
@ -136,6 +137,15 @@ local function apply_inline_row(bufnr, row, m, icons)
|
||||||
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, {
|
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, {
|
||||||
end_col = #line,
|
end_col = #line,
|
||||||
hl_group = 'PendingDone',
|
hl_group = 'PendingDone',
|
||||||
|
invalidate = true,
|
||||||
|
})
|
||||||
|
elseif m.status == 'cancelled' 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 = 'PendingCancelled',
|
||||||
|
invalidate = true,
|
||||||
})
|
})
|
||||||
elseif m.status == 'blocked' then
|
elseif m.status == 'blocked' then
|
||||||
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
|
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
|
||||||
|
|
@ -143,6 +153,7 @@ local function apply_inline_row(bufnr, row, m, icons)
|
||||||
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, {
|
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, {
|
||||||
end_col = #line,
|
end_col = #line,
|
||||||
hl_group = 'PendingBlocked',
|
hl_group = 'PendingBlocked',
|
||||||
|
invalidate = true,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
|
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
|
||||||
|
|
@ -150,10 +161,12 @@ local function apply_inline_row(bufnr, row, m, icons)
|
||||||
local icon, icon_hl
|
local icon, icon_hl
|
||||||
if m.status == 'done' then
|
if m.status == 'done' then
|
||||||
icon, icon_hl = icons.done, 'PendingDone'
|
icon, icon_hl = icons.done, 'PendingDone'
|
||||||
|
elseif m.status == 'cancelled' then
|
||||||
|
icon, icon_hl = icons.cancelled, 'PendingCancelled'
|
||||||
elseif m.status == 'wip' then
|
elseif m.status == 'wip' then
|
||||||
icon, icon_hl = icons.wip or '>', 'PendingWip'
|
icon, icon_hl = icons.wip, 'PendingWip'
|
||||||
elseif m.status == 'blocked' then
|
elseif m.status == 'blocked' then
|
||||||
icon, icon_hl = icons.blocked or '=', 'PendingBlocked'
|
icon, icon_hl = icons.blocked, 'PendingBlocked'
|
||||||
elseif m.priority and m.priority >= 3 then
|
elseif m.priority and m.priority >= 3 then
|
||||||
icon, icon_hl = icons.priority, 'PendingPriority3'
|
icon, icon_hl = icons.priority, 'PendingPriority3'
|
||||||
elseif m.priority and m.priority == 2 then
|
elseif m.priority and m.priority == 2 then
|
||||||
|
|
@ -167,6 +180,7 @@ local function apply_inline_row(bufnr, row, m, icons)
|
||||||
virt_text = { { '[' .. icon .. ']', icon_hl } },
|
virt_text = { { '[' .. icon .. ']', icon_hl } },
|
||||||
virt_text_pos = 'overlay',
|
virt_text_pos = 'overlay',
|
||||||
priority = 100,
|
priority = 100,
|
||||||
|
invalidate = true,
|
||||||
})
|
})
|
||||||
if m.forge_spans then
|
if m.forge_spans then
|
||||||
local forge = require('pending.forge')
|
local forge = require('pending.forge')
|
||||||
|
|
@ -178,6 +192,7 @@ local function apply_inline_row(bufnr, row, m, icons)
|
||||||
virt_text = { { label_text, hl_group } },
|
virt_text = { { label_text, hl_group } },
|
||||||
virt_text_pos = 'inline',
|
virt_text_pos = 'inline',
|
||||||
priority = 90,
|
priority = 90,
|
||||||
|
invalidate = true,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -186,11 +201,13 @@ local function apply_inline_row(bufnr, row, m, icons)
|
||||||
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, {
|
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, {
|
||||||
end_col = #line,
|
end_col = #line,
|
||||||
hl_group = 'PendingHeader',
|
hl_group = 'PendingHeader',
|
||||||
|
invalidate = true,
|
||||||
})
|
})
|
||||||
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, {
|
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, {
|
||||||
virt_text = { { icons.category .. ' ', 'PendingHeader' } },
|
virt_text = { { icons.category .. ' ', 'PendingHeader' } },
|
||||||
virt_text_pos = 'overlay',
|
virt_text_pos = 'overlay',
|
||||||
priority = 100,
|
priority = 100,
|
||||||
|
invalidate = true,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -202,11 +219,14 @@ local function infer_status(line)
|
||||||
if not ch then
|
if not ch then
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
if ch == 'x' then
|
local icons = config.get().icons
|
||||||
|
if ch == icons.done then
|
||||||
return 'done'
|
return 'done'
|
||||||
elseif ch == '>' then
|
elseif ch == icons.cancelled then
|
||||||
|
return 'cancelled'
|
||||||
|
elseif ch == icons.wip then
|
||||||
return 'wip'
|
return 'wip'
|
||||||
elseif ch == '=' then
|
elseif ch == icons.blocked then
|
||||||
return 'blocked'
|
return 'blocked'
|
||||||
end
|
end
|
||||||
return 'pending'
|
return 'pending'
|
||||||
|
|
@ -541,6 +561,7 @@ local function apply_extmarks(bufnr, line_meta)
|
||||||
vim.api.nvim_buf_set_extmark(bufnr, ns_eol, row, 0, {
|
vim.api.nvim_buf_set_extmark(bufnr, ns_eol, row, 0, {
|
||||||
virt_text = virt_parts,
|
virt_text = virt_parts,
|
||||||
virt_text_pos = 'eol',
|
virt_text_pos = 'eol',
|
||||||
|
invalidate = true,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -558,10 +579,12 @@ local function setup_highlights()
|
||||||
vim.api.nvim_set_hl(0, 'PendingPriority3', { link = 'DiagnosticError', default = true })
|
vim.api.nvim_set_hl(0, 'PendingPriority3', { link = 'DiagnosticError', default = true })
|
||||||
vim.api.nvim_set_hl(0, 'PendingWip', { link = 'DiagnosticInfo', default = true })
|
vim.api.nvim_set_hl(0, 'PendingWip', { link = 'DiagnosticInfo', default = true })
|
||||||
vim.api.nvim_set_hl(0, 'PendingBlocked', { link = 'DiagnosticError', default = true })
|
vim.api.nvim_set_hl(0, 'PendingBlocked', { link = 'DiagnosticError', default = true })
|
||||||
|
vim.api.nvim_set_hl(0, 'PendingCancelled', { link = 'NonText', default = true })
|
||||||
vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true })
|
vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true })
|
||||||
vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true })
|
vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true })
|
||||||
vim.api.nvim_set_hl(0, 'PendingForge', { link = 'DiagnosticInfo', default = true })
|
vim.api.nvim_set_hl(0, 'PendingForge', { link = 'DiagnosticInfo', default = true })
|
||||||
vim.api.nvim_set_hl(0, 'PendingForgeClosed', { link = 'Comment', default = true })
|
vim.api.nvim_set_hl(0, 'PendingForgeClosed', { link = 'Comment', default = true })
|
||||||
|
vim.api.nvim_set_hl(0, 'PendingDetailMeta', { link = 'Comment', default = true })
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return string
|
---@return string
|
||||||
|
|
@ -783,4 +806,329 @@ function M.open()
|
||||||
return task_bufnr
|
return task_bufnr
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local ns_detail = vim.api.nvim_create_namespace('pending_detail')
|
||||||
|
local DETAIL_SEPARATOR = '---'
|
||||||
|
|
||||||
|
---@type integer?
|
||||||
|
local _detail_bufnr = nil
|
||||||
|
---@type integer?
|
||||||
|
local _detail_task_id = nil
|
||||||
|
|
||||||
|
---@return integer?
|
||||||
|
function M.detail_bufnr()
|
||||||
|
return _detail_bufnr
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return integer?
|
||||||
|
function M.detail_task_id()
|
||||||
|
return _detail_task_id
|
||||||
|
end
|
||||||
|
|
||||||
|
local VALID_STATUSES = {
|
||||||
|
pending = true,
|
||||||
|
done = true,
|
||||||
|
wip = true,
|
||||||
|
blocked = true,
|
||||||
|
cancelled = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
---@param task pending.Task
|
||||||
|
---@return string[]
|
||||||
|
local function build_detail_frontmatter(task)
|
||||||
|
local lines = {}
|
||||||
|
table.insert(lines, 'Status: ' .. (task.status or 'pending'))
|
||||||
|
table.insert(lines, 'Priority: ' .. (task.priority or 0))
|
||||||
|
if task.category then
|
||||||
|
table.insert(lines, 'Category: ' .. task.category)
|
||||||
|
end
|
||||||
|
if task.due then
|
||||||
|
table.insert(lines, 'Due: ' .. task.due)
|
||||||
|
end
|
||||||
|
if task.recur then
|
||||||
|
local recur_val = task.recur
|
||||||
|
if task.recur_mode == 'completion' then
|
||||||
|
recur_val = '!' .. recur_val
|
||||||
|
end
|
||||||
|
table.insert(lines, 'Recur: ' .. recur_val)
|
||||||
|
end
|
||||||
|
return lines
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@param sep_row integer
|
||||||
|
---@return nil
|
||||||
|
local function apply_detail_extmarks(bufnr, sep_row)
|
||||||
|
vim.api.nvim_buf_clear_namespace(bufnr, ns_detail, 0, -1)
|
||||||
|
for i = 1, sep_row - 1 do
|
||||||
|
vim.api.nvim_buf_set_extmark(bufnr, ns_detail, i, 0, {
|
||||||
|
end_row = i,
|
||||||
|
end_col = #(vim.api.nvim_buf_get_lines(bufnr, i, i + 1, false)[1] or ''),
|
||||||
|
hl_group = 'PendingDetailMeta',
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param task_id integer
|
||||||
|
---@return integer? bufnr
|
||||||
|
function M.open_detail(task_id)
|
||||||
|
if not _store then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if _detail_bufnr and vim.api.nvim_buf_is_valid(_detail_bufnr) then
|
||||||
|
if _detail_task_id == task_id then
|
||||||
|
return _detail_bufnr
|
||||||
|
end
|
||||||
|
vim.api.nvim_buf_delete(_detail_bufnr, { force = true })
|
||||||
|
_detail_bufnr = nil
|
||||||
|
_detail_task_id = nil
|
||||||
|
end
|
||||||
|
local task = _store:get(task_id)
|
||||||
|
if not task then
|
||||||
|
log.warn('task not found: ' .. task_id)
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
setup_highlights()
|
||||||
|
|
||||||
|
local bufnr = vim.api.nvim_create_buf(true, false)
|
||||||
|
vim.api.nvim_buf_set_name(bufnr, 'pending://task/' .. task_id)
|
||||||
|
vim.bo[bufnr].buftype = 'acwrite'
|
||||||
|
vim.bo[bufnr].filetype = 'markdown'
|
||||||
|
vim.bo[bufnr].swapfile = false
|
||||||
|
|
||||||
|
local lines = { '# ' .. task.description }
|
||||||
|
local fm = build_detail_frontmatter(task)
|
||||||
|
for _, fl in ipairs(fm) do
|
||||||
|
table.insert(lines, fl)
|
||||||
|
end
|
||||||
|
table.insert(lines, DETAIL_SEPARATOR)
|
||||||
|
local notes = task.notes or ''
|
||||||
|
if notes ~= '' then
|
||||||
|
for note_line in (notes .. '\n'):gmatch('(.-)\n') do
|
||||||
|
table.insert(lines, note_line)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
table.insert(lines, '')
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
|
||||||
|
vim.bo[bufnr].modified = false
|
||||||
|
|
||||||
|
local sep_row = #fm + 1
|
||||||
|
apply_detail_extmarks(bufnr, sep_row)
|
||||||
|
|
||||||
|
local winid = task_winid
|
||||||
|
if winid and vim.api.nvim_win_is_valid(winid) then
|
||||||
|
vim.api.nvim_win_set_buf(winid, bufnr)
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.wo[winid].conceallevel = 0
|
||||||
|
vim.wo[winid].foldmethod = 'manual'
|
||||||
|
vim.wo[winid].foldenable = false
|
||||||
|
|
||||||
|
_detail_bufnr = bufnr
|
||||||
|
_detail_task_id = task_id
|
||||||
|
|
||||||
|
local cursor_row = sep_row + 2
|
||||||
|
local total = vim.api.nvim_buf_line_count(bufnr)
|
||||||
|
if cursor_row > total then
|
||||||
|
cursor_row = total
|
||||||
|
end
|
||||||
|
pcall(vim.api.nvim_win_set_cursor, winid, { cursor_row, 0 })
|
||||||
|
|
||||||
|
return bufnr
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
|
function M.close_detail()
|
||||||
|
if _detail_bufnr and vim.api.nvim_buf_is_valid(_detail_bufnr) then
|
||||||
|
vim.api.nvim_buf_delete(_detail_bufnr, { force = true })
|
||||||
|
end
|
||||||
|
_detail_bufnr = nil
|
||||||
|
_detail_task_id = nil
|
||||||
|
|
||||||
|
if task_winid and vim.api.nvim_win_is_valid(task_winid) then
|
||||||
|
if task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr) then
|
||||||
|
vim.api.nvim_win_set_buf(task_winid, task_bufnr)
|
||||||
|
set_win_options(task_winid)
|
||||||
|
M.render(task_bufnr)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param lines string[]
|
||||||
|
---@return integer? sep_row
|
||||||
|
---@return pending.DetailFields? fields
|
||||||
|
---@return string? err
|
||||||
|
local function parse_detail_frontmatter(lines)
|
||||||
|
local parse = require('pending.parse')
|
||||||
|
local recur = require('pending.recur')
|
||||||
|
local cfg = config.get()
|
||||||
|
|
||||||
|
local sep_row = nil
|
||||||
|
for i, line in ipairs(lines) do
|
||||||
|
if line == DETAIL_SEPARATOR then
|
||||||
|
sep_row = i
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not sep_row then
|
||||||
|
return nil, nil, 'missing separator (---)'
|
||||||
|
end
|
||||||
|
|
||||||
|
local desc = lines[1] and lines[1]:match('^# (.+)$')
|
||||||
|
if not desc or desc:match('^%s*$') then
|
||||||
|
return nil, nil, 'missing or empty title (first line must be # <title>)'
|
||||||
|
end
|
||||||
|
|
||||||
|
---@class pending.DetailFields
|
||||||
|
---@field description string
|
||||||
|
---@field status pending.TaskStatus
|
||||||
|
---@field priority integer
|
||||||
|
---@field category? string|userdata
|
||||||
|
---@field due? string|userdata
|
||||||
|
---@field recur? string|userdata
|
||||||
|
---@field recur_mode? pending.RecurMode|userdata
|
||||||
|
local fields = {
|
||||||
|
description = desc,
|
||||||
|
status = 'pending',
|
||||||
|
priority = 0,
|
||||||
|
category = vim.NIL,
|
||||||
|
due = vim.NIL,
|
||||||
|
recur = vim.NIL,
|
||||||
|
recur_mode = vim.NIL,
|
||||||
|
}
|
||||||
|
|
||||||
|
local seen = {} ---@type table<string, boolean>
|
||||||
|
for i = 2, sep_row - 1 do
|
||||||
|
local line = lines[i]
|
||||||
|
if line:match('^%s*$') then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
local key, val = line:match('^(%S+):%s*(.*)$')
|
||||||
|
if not key then
|
||||||
|
return nil, nil, 'invalid frontmatter line: ' .. line
|
||||||
|
end
|
||||||
|
key = key:lower()
|
||||||
|
if seen[key] then
|
||||||
|
return nil, nil, 'duplicate field: ' .. key
|
||||||
|
end
|
||||||
|
seen[key] = true
|
||||||
|
|
||||||
|
if key == 'status' then
|
||||||
|
val = val:lower()
|
||||||
|
if not VALID_STATUSES[val] then
|
||||||
|
return nil, nil, 'invalid status: ' .. val
|
||||||
|
end
|
||||||
|
fields.status = val --[[@as pending.TaskStatus]]
|
||||||
|
elseif key == 'priority' then
|
||||||
|
local n = tonumber(val)
|
||||||
|
if not n or n ~= math.floor(n) or n < 0 then
|
||||||
|
return nil, nil, 'invalid priority: ' .. val .. ' (must be integer >= 0)'
|
||||||
|
end
|
||||||
|
local max = cfg.max_priority or 3
|
||||||
|
if n > max then
|
||||||
|
return nil, nil, 'invalid priority: ' .. val .. ' (max is ' .. max .. ')'
|
||||||
|
end
|
||||||
|
fields.priority = n --[[@as integer]]
|
||||||
|
elseif key == 'category' then
|
||||||
|
if val == '' then
|
||||||
|
return nil, nil, 'empty category value'
|
||||||
|
end
|
||||||
|
fields.category = val
|
||||||
|
elseif key == 'due' then
|
||||||
|
if val == '' then
|
||||||
|
return nil, nil, 'empty due value (remove the line to clear)'
|
||||||
|
end
|
||||||
|
local resolved = parse.resolve_date(val)
|
||||||
|
if resolved then
|
||||||
|
fields.due = resolved
|
||||||
|
elseif
|
||||||
|
val:match('^%d%d%d%d%-%d%d%-%d%d$') or val:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$')
|
||||||
|
then
|
||||||
|
fields.due = val
|
||||||
|
else
|
||||||
|
return nil, nil, 'invalid due date: ' .. val
|
||||||
|
end
|
||||||
|
elseif key == 'recur' then
|
||||||
|
if val == '' then
|
||||||
|
return nil, nil, 'empty recur value (remove the line to clear)'
|
||||||
|
end
|
||||||
|
local raw_spec = 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: ' .. val
|
||||||
|
end
|
||||||
|
fields.recur = raw_spec
|
||||||
|
fields.recur_mode = rec_mode or vim.NIL
|
||||||
|
else
|
||||||
|
return nil, nil, 'unknown field: ' .. key
|
||||||
|
end
|
||||||
|
::continue::
|
||||||
|
end
|
||||||
|
|
||||||
|
return sep_row, fields, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
|
function M.save_detail()
|
||||||
|
if not _detail_bufnr or not _detail_task_id or not _store then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local task = _store:get(_detail_task_id)
|
||||||
|
if not task then
|
||||||
|
log.warn('task was deleted')
|
||||||
|
M.close_detail()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(_detail_bufnr, 0, -1, false)
|
||||||
|
|
||||||
|
local sep_row, fields, err = parse_detail_frontmatter(lines)
|
||||||
|
if err then
|
||||||
|
log.error(err)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
---@cast sep_row integer
|
||||||
|
---@cast fields pending.DetailFields
|
||||||
|
|
||||||
|
local notes_text = ''
|
||||||
|
if sep_row < #lines then
|
||||||
|
local note_lines = {}
|
||||||
|
for i = sep_row + 1, #lines do
|
||||||
|
table.insert(note_lines, lines[i])
|
||||||
|
end
|
||||||
|
notes_text = table.concat(note_lines, '\n')
|
||||||
|
notes_text = notes_text:gsub('%s+$', '')
|
||||||
|
end
|
||||||
|
|
||||||
|
local update = {
|
||||||
|
description = fields.description,
|
||||||
|
status = fields.status,
|
||||||
|
priority = fields.priority,
|
||||||
|
category = fields.category,
|
||||||
|
due = fields.due,
|
||||||
|
recur = fields.recur,
|
||||||
|
recur_mode = fields.recur_mode,
|
||||||
|
}
|
||||||
|
if notes_text == '' then
|
||||||
|
update.notes = vim.NIL
|
||||||
|
else
|
||||||
|
update.notes = notes_text
|
||||||
|
end
|
||||||
|
|
||||||
|
_store:update(_detail_task_id, update)
|
||||||
|
_store:save()
|
||||||
|
|
||||||
|
vim.bo[_detail_bufnr].modified = false
|
||||||
|
apply_detail_extmarks(_detail_bufnr, sep_row - 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
M._parse_detail_frontmatter = parse_detail_frontmatter
|
||||||
|
M._build_detail_frontmatter = build_detail_frontmatter
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
local config = require('pending.config')
|
local config = require('pending.config')
|
||||||
|
local forge = require('pending.forge')
|
||||||
|
|
||||||
---@class pending.CompletionItem
|
---@class pending.CompletionItem
|
||||||
---@field word string
|
---@field word string
|
||||||
|
|
@ -109,6 +110,17 @@ local function recur_completions()
|
||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@param source string
|
||||||
|
---@return boolean
|
||||||
|
function M._is_forge_source(source)
|
||||||
|
for _, b in ipairs(forge.backends()) do
|
||||||
|
if b.shorthand == source then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
---@type string?
|
---@type string?
|
||||||
local _complete_source = nil
|
local _complete_source = nil
|
||||||
|
|
||||||
|
|
@ -124,14 +136,16 @@ function M.omnifunc(findstart, base)
|
||||||
local dk = date_key()
|
local dk = date_key()
|
||||||
local rk = recur_key()
|
local rk = recur_key()
|
||||||
|
|
||||||
|
local ck = config.get().category_syntax or 'cat'
|
||||||
|
|
||||||
local checks = {
|
local checks = {
|
||||||
{ vim.pesc(dk) .. ':([%S]*)$', dk },
|
{ vim.pesc(dk) .. ':([%S]*)$', dk },
|
||||||
{ 'cat:([%S]*)$', 'cat' },
|
{ vim.pesc(ck) .. ':([%S]*)$', ck },
|
||||||
{ vim.pesc(rk) .. ':([%S]*)$', rk },
|
{ vim.pesc(rk) .. ':([%S]*)$', rk },
|
||||||
{ 'gh:([%S]*)$', 'gh' },
|
|
||||||
{ 'gl:([%S]*)$', 'gl' },
|
|
||||||
{ 'cb:([%S]*)$', 'cb' },
|
|
||||||
}
|
}
|
||||||
|
for _, b in ipairs(forge.backends()) do
|
||||||
|
table.insert(checks, { vim.pesc(b.shorthand) .. ':([%S]*)$', b.shorthand })
|
||||||
|
end
|
||||||
|
|
||||||
for _, check in ipairs(checks) do
|
for _, check in ipairs(checks) do
|
||||||
local start = before:find(check[1])
|
local start = before:find(check[1])
|
||||||
|
|
@ -160,10 +174,10 @@ function M.omnifunc(findstart, base)
|
||||||
table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info })
|
table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
elseif source == 'cat' then
|
elseif source == (config.get().category_syntax or 'cat') then
|
||||||
for _, c in ipairs(get_categories()) do
|
for _, c in ipairs(get_categories()) do
|
||||||
if base == '' or c:sub(1, #base) == base then
|
if base == '' or c:sub(1, #base) == base then
|
||||||
table.insert(matches, { word = c, menu = '[cat]' })
|
table.insert(matches, { word = c, menu = '[' .. source .. ']' })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
elseif source == rk then
|
elseif source == rk then
|
||||||
|
|
@ -172,19 +186,25 @@ function M.omnifunc(findstart, base)
|
||||||
table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info })
|
table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
elseif source == 'gh' or source == 'gl' or source == 'cb' then
|
elseif M._is_forge_source(source) then
|
||||||
local s = require('pending.buffer').store()
|
local s = require('pending.buffer').store()
|
||||||
if s then
|
if s then
|
||||||
local seen = {}
|
local seen = {}
|
||||||
for _, task in ipairs(s:tasks()) do
|
for _, task in ipairs(s:tasks()) do
|
||||||
if task._extra and task._extra._forge_ref then
|
if task._extra and task._extra._forge_ref then
|
||||||
local ref = task._extra._forge_ref
|
local ref = task._extra._forge_ref --[[@as pending.ForgeRef]]
|
||||||
local key = ref.owner .. '/' .. ref.repo
|
local key = ref.owner .. '/' .. ref.repo
|
||||||
if not seen[key] then
|
if not seen[key] then
|
||||||
seen[key] = true
|
seen[key] = true
|
||||||
local word = key .. '#'
|
local word_num = key .. '#'
|
||||||
if base == '' or word:sub(1, #base) == base then
|
if base == '' or word_num:sub(1, #base) == base then
|
||||||
table.insert(matches, { word = word, menu = '[' .. source .. ']' })
|
table.insert(matches, { word = word_num, menu = '[' .. source .. ']' })
|
||||||
|
end
|
||||||
|
if base == '' or key:sub(1, #base) == base then
|
||||||
|
table.insert(
|
||||||
|
matches,
|
||||||
|
{ word = key, menu = '[' .. source .. ']', info = 'Bare repo link' }
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
---@field priority string
|
---@field priority string
|
||||||
---@field wip string
|
---@field wip string
|
||||||
---@field blocked string
|
---@field blocked string
|
||||||
|
---@field cancelled string
|
||||||
---@field due string
|
---@field due string
|
||||||
---@field recur string
|
---@field recur string
|
||||||
---@field category string
|
---@field category string
|
||||||
|
|
@ -37,10 +38,15 @@
|
||||||
---@field icon? string
|
---@field icon? string
|
||||||
---@field issue_format? string
|
---@field issue_format? string
|
||||||
---@field instances? string[]
|
---@field instances? string[]
|
||||||
|
---@field shorthand? string
|
||||||
|
|
||||||
---@class pending.ForgeConfig
|
---@class pending.ForgeConfig
|
||||||
---@field auto_close? boolean
|
---@field close? boolean
|
||||||
|
---@field validate? boolean
|
||||||
---@field warn_missing_cli? boolean
|
---@field warn_missing_cli? boolean
|
||||||
|
---@field github? pending.ForgeInstanceConfig
|
||||||
|
---@field gitlab? pending.ForgeInstanceConfig
|
||||||
|
---@field codeberg? pending.ForgeInstanceConfig
|
||||||
---@field [string] pending.ForgeInstanceConfig
|
---@field [string] pending.ForgeInstanceConfig
|
||||||
|
|
||||||
---@class pending.SyncConfig
|
---@class pending.SyncConfig
|
||||||
|
|
@ -73,12 +79,17 @@
|
||||||
---@field move_up? string|false
|
---@field move_up? string|false
|
||||||
---@field wip? string|false
|
---@field wip? string|false
|
||||||
---@field blocked? string|false
|
---@field blocked? string|false
|
||||||
|
---@field priority_up_visual? string|false
|
||||||
|
---@field priority_down_visual? string|false
|
||||||
|
---@field cancelled? string|false
|
||||||
|
---@field edit_notes? string|false
|
||||||
|
|
||||||
---@class pending.CategoryViewConfig
|
---@class pending.CategoryViewConfig
|
||||||
---@field order? string[]
|
---@field order? string[]
|
||||||
---@field folding? boolean|pending.FoldingConfig
|
---@field folding? boolean|pending.FoldingConfig
|
||||||
|
|
||||||
---@class pending.QueueViewConfig
|
---@class pending.QueueViewConfig
|
||||||
|
---@field sort? string[]
|
||||||
|
|
||||||
---@class pending.ViewConfig
|
---@class pending.ViewConfig
|
||||||
---@field default? 'category'|'priority'
|
---@field default? 'category'|'priority'
|
||||||
|
|
@ -90,6 +101,7 @@
|
||||||
---@field data_path string
|
---@field data_path string
|
||||||
---@field default_category string
|
---@field default_category string
|
||||||
---@field date_format string
|
---@field date_format string
|
||||||
|
---@field category_syntax string
|
||||||
---@field date_syntax string
|
---@field date_syntax string
|
||||||
---@field recur_syntax string
|
---@field recur_syntax string
|
||||||
---@field someday_date string
|
---@field someday_date string
|
||||||
|
|
@ -111,6 +123,7 @@ local defaults = {
|
||||||
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
|
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
|
||||||
default_category = 'Todo',
|
default_category = 'Todo',
|
||||||
date_format = '%b %d',
|
date_format = '%b %d',
|
||||||
|
category_syntax = 'cat',
|
||||||
date_syntax = 'due',
|
date_syntax = 'due',
|
||||||
recur_syntax = 'rec',
|
recur_syntax = 'rec',
|
||||||
someday_date = '9999-12-30',
|
someday_date = '9999-12-30',
|
||||||
|
|
@ -122,7 +135,9 @@ local defaults = {
|
||||||
order = {},
|
order = {},
|
||||||
folding = true,
|
folding = true,
|
||||||
},
|
},
|
||||||
queue = {},
|
queue = {
|
||||||
|
sort = { 'status', 'priority', 'due', 'order', 'id' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
keymaps = {
|
keymaps = {
|
||||||
close = 'q',
|
close = 'q',
|
||||||
|
|
@ -148,12 +163,17 @@ local defaults = {
|
||||||
move_up = 'K',
|
move_up = 'K',
|
||||||
wip = 'gw',
|
wip = 'gw',
|
||||||
blocked = 'gb',
|
blocked = 'gb',
|
||||||
|
cancelled = 'g/',
|
||||||
|
edit_notes = 'ge',
|
||||||
priority_up = '<C-a>',
|
priority_up = '<C-a>',
|
||||||
priority_down = '<C-x>',
|
priority_down = '<C-x>',
|
||||||
|
priority_up_visual = 'g<C-a>',
|
||||||
|
priority_down_visual = 'g<C-x>',
|
||||||
},
|
},
|
||||||
sync = {},
|
sync = {},
|
||||||
forge = {
|
forge = {
|
||||||
auto_close = false,
|
close = false,
|
||||||
|
validate = false,
|
||||||
warn_missing_cli = true,
|
warn_missing_cli = true,
|
||||||
github = {
|
github = {
|
||||||
icon = '',
|
icon = '',
|
||||||
|
|
@ -175,8 +195,9 @@ local defaults = {
|
||||||
pending = ' ',
|
pending = ' ',
|
||||||
done = 'x',
|
done = 'x',
|
||||||
priority = '!',
|
priority = '!',
|
||||||
wip = '>',
|
wip = 'w',
|
||||||
blocked = '=',
|
blocked = 'b',
|
||||||
|
cancelled = '/',
|
||||||
due = '.',
|
due = '.',
|
||||||
recur = '~',
|
recur = '~',
|
||||||
category = '#',
|
category = '#',
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,11 @@ local parse = require('pending.parse')
|
||||||
---@field id? integer
|
---@field id? integer
|
||||||
---@field description? string
|
---@field description? string
|
||||||
---@field priority? integer
|
---@field priority? integer
|
||||||
---@field status? string
|
---@field status? pending.TaskStatus
|
||||||
---@field category? string
|
---@field category? string
|
||||||
---@field due? string
|
---@field due? string
|
||||||
---@field rec? string
|
---@field recur? string
|
||||||
---@field rec_mode? string
|
---@field recur_mode? pending.RecurMode
|
||||||
---@field forge_ref? pending.ForgeRef
|
---@field forge_ref? pending.ForgeRef
|
||||||
---@field lnum integer
|
---@field lnum integer
|
||||||
|
|
||||||
|
|
@ -43,14 +43,17 @@ function M.parse_buffer(lines)
|
||||||
table.insert(result, { type = 'blank', lnum = i })
|
table.insert(result, { type = 'blank', lnum = i })
|
||||||
elseif id or body then
|
elseif id or body then
|
||||||
local stripped = body:match('^- %[.?%] (.*)$') or body
|
local stripped = body:match('^- %[.?%] (.*)$') or body
|
||||||
local state_char = body:match('^- %[(.-)%]') or ' '
|
local icons = config.get().icons
|
||||||
local priority = state_char == '!' and 1 or 0
|
local state_char = body:match('^- %[(.-)%]') or icons.pending
|
||||||
|
local priority = state_char == icons.priority and 1 or 0
|
||||||
local status
|
local status
|
||||||
if state_char == 'x' then
|
if state_char == icons.done then
|
||||||
status = 'done'
|
status = 'done'
|
||||||
elseif state_char == '>' then
|
elseif state_char == icons.cancelled then
|
||||||
|
status = 'cancelled'
|
||||||
|
elseif state_char == icons.wip then
|
||||||
status = 'wip'
|
status = 'wip'
|
||||||
elseif state_char == '=' then
|
elseif state_char == icons.blocked then
|
||||||
status = 'blocked'
|
status = 'blocked'
|
||||||
else
|
else
|
||||||
status = 'pending'
|
status = 'pending'
|
||||||
|
|
@ -63,12 +66,12 @@ function M.parse_buffer(lines)
|
||||||
type = 'task',
|
type = 'task',
|
||||||
id = id and tonumber(id) or nil,
|
id = id and tonumber(id) or nil,
|
||||||
description = description,
|
description = description,
|
||||||
priority = priority,
|
priority = metadata.priority or priority,
|
||||||
status = status,
|
status = status,
|
||||||
category = metadata.cat or current_category or config.get().default_category,
|
category = metadata.category or current_category or config.get().default_category,
|
||||||
due = metadata.due,
|
due = metadata.due,
|
||||||
rec = metadata.rec,
|
recur = metadata.recur,
|
||||||
rec_mode = metadata.rec_mode,
|
recur_mode = metadata.recur_mode,
|
||||||
forge_ref = forge_ref,
|
forge_ref = forge_ref,
|
||||||
lnum = i,
|
lnum = i,
|
||||||
})
|
})
|
||||||
|
|
@ -82,14 +85,25 @@ function M.parse_buffer(lines)
|
||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@param a? pending.ForgeRef
|
||||||
|
---@param b? pending.ForgeRef
|
||||||
|
---@return boolean
|
||||||
|
local function refs_equal(a, b)
|
||||||
|
if not a or not b then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return a.forge == b.forge and a.owner == b.owner and a.repo == b.repo and a.number == b.number
|
||||||
|
end
|
||||||
|
|
||||||
---@param lines string[]
|
---@param lines string[]
|
||||||
---@param s pending.Store
|
---@param s pending.Store
|
||||||
---@param hidden_ids? table<integer, true>
|
---@param hidden_ids? table<integer, true>
|
||||||
---@return nil
|
---@return pending.ForgeRef[]
|
||||||
function M.apply(lines, s, hidden_ids)
|
function M.apply(lines, s, hidden_ids)
|
||||||
local parsed = M.parse_buffer(lines)
|
local parsed = M.parse_buffer(lines)
|
||||||
local now = timestamp()
|
local now = timestamp()
|
||||||
local data = s:data()
|
local data = s:data()
|
||||||
|
local new_refs = {} ---@type pending.ForgeRef[]
|
||||||
|
|
||||||
local old_by_id = {}
|
local old_by_id = {}
|
||||||
for _, task in ipairs(data.tasks) do
|
for _, task in ipairs(data.tasks) do
|
||||||
|
|
@ -115,11 +129,14 @@ function M.apply(lines, s, hidden_ids)
|
||||||
category = entry.category,
|
category = entry.category,
|
||||||
priority = entry.priority,
|
priority = entry.priority,
|
||||||
due = entry.due,
|
due = entry.due,
|
||||||
recur = entry.rec,
|
recur = entry.recur,
|
||||||
recur_mode = entry.rec_mode,
|
recur_mode = entry.recur_mode,
|
||||||
order = order_counter,
|
order = order_counter,
|
||||||
_extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil,
|
_extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil,
|
||||||
})
|
})
|
||||||
|
if entry.forge_ref then
|
||||||
|
table.insert(new_refs, entry.forge_ref)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
seen_ids[entry.id] = true
|
seen_ids[entry.id] = true
|
||||||
local task = old_by_id[entry.id]
|
local task = old_by_id[entry.id]
|
||||||
|
|
@ -132,10 +149,7 @@ function M.apply(lines, s, hidden_ids)
|
||||||
task.category = entry.category
|
task.category = entry.category
|
||||||
changed = true
|
changed = true
|
||||||
end
|
end
|
||||||
if entry.priority == 0 and task.priority > 0 then
|
if entry.priority ~= task.priority then
|
||||||
task.priority = 0
|
|
||||||
changed = true
|
|
||||||
elseif entry.priority > 0 and task.priority == 0 then
|
|
||||||
task.priority = entry.priority
|
task.priority = entry.priority
|
||||||
changed = true
|
changed = true
|
||||||
end
|
end
|
||||||
|
|
@ -143,17 +157,21 @@ function M.apply(lines, s, hidden_ids)
|
||||||
task.due = entry.due
|
task.due = entry.due
|
||||||
changed = true
|
changed = true
|
||||||
end
|
end
|
||||||
if entry.rec ~= nil then
|
if entry.recur ~= nil then
|
||||||
if task.recur ~= entry.rec then
|
if task.recur ~= entry.recur then
|
||||||
task.recur = entry.rec
|
task.recur = entry.recur
|
||||||
changed = true
|
changed = true
|
||||||
end
|
end
|
||||||
if task.recur_mode ~= entry.rec_mode then
|
if task.recur_mode ~= entry.recur_mode then
|
||||||
task.recur_mode = entry.rec_mode
|
task.recur_mode = entry.recur_mode
|
||||||
changed = true
|
changed = true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if entry.forge_ref ~= nil then
|
if entry.forge_ref ~= nil then
|
||||||
|
local old_ref = task._extra and task._extra._forge_ref or nil
|
||||||
|
if not refs_equal(old_ref, entry.forge_ref) then
|
||||||
|
table.insert(new_refs, entry.forge_ref)
|
||||||
|
end
|
||||||
if not task._extra then
|
if not task._extra then
|
||||||
task._extra = {}
|
task._extra = {}
|
||||||
end
|
end
|
||||||
|
|
@ -162,7 +180,7 @@ function M.apply(lines, s, hidden_ids)
|
||||||
end
|
end
|
||||||
if entry.status and task.status ~= entry.status then
|
if entry.status and task.status ~= entry.status then
|
||||||
task.status = entry.status
|
task.status = entry.status
|
||||||
if entry.status == 'done' then
|
if entry.status == 'done' or entry.status == 'cancelled' then
|
||||||
task['end'] = now
|
task['end'] = now
|
||||||
else
|
else
|
||||||
task['end'] = nil
|
task['end'] = nil
|
||||||
|
|
@ -183,11 +201,14 @@ function M.apply(lines, s, hidden_ids)
|
||||||
category = entry.category,
|
category = entry.category,
|
||||||
priority = entry.priority,
|
priority = entry.priority,
|
||||||
due = entry.due,
|
due = entry.due,
|
||||||
recur = entry.rec,
|
recur = entry.recur,
|
||||||
recur_mode = entry.rec_mode,
|
recur_mode = entry.recur_mode,
|
||||||
order = order_counter,
|
order = order_counter,
|
||||||
_extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil,
|
_extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil,
|
||||||
})
|
})
|
||||||
|
if entry.forge_ref then
|
||||||
|
table.insert(new_refs, entry.forge_ref)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
::continue::
|
::continue::
|
||||||
|
|
@ -202,6 +223,7 @@ function M.apply(lines, s, hidden_ids)
|
||||||
end
|
end
|
||||||
|
|
||||||
s:save()
|
s:save()
|
||||||
|
return new_refs
|
||||||
end
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,40 @@
|
||||||
local config = require('pending.config')
|
local config = require('pending.config')
|
||||||
local log = require('pending.log')
|
local log = require('pending.log')
|
||||||
|
|
||||||
|
---@alias pending.ForgeType 'issue'|'pull_request'|'merge_request'|'repo'
|
||||||
|
---@alias pending.ForgeState 'open'|'closed'|'merged'
|
||||||
|
---@alias pending.ForgeAuthStatus 'unknown'|'ok'|'failed'
|
||||||
|
|
||||||
---@class pending.ForgeRef
|
---@class pending.ForgeRef
|
||||||
---@field forge string
|
---@field forge string
|
||||||
---@field owner string
|
---@field owner string
|
||||||
---@field repo string
|
---@field repo string
|
||||||
---@field type 'issue'|'pull_request'|'merge_request'
|
---@field type pending.ForgeType
|
||||||
---@field number integer
|
---@field number? integer
|
||||||
---@field url string
|
---@field url string
|
||||||
|
|
||||||
---@class pending.ForgeCache
|
---@class pending.ForgeCache
|
||||||
---@field title? string
|
---@field title? string
|
||||||
---@field state 'open'|'closed'|'merged'
|
---@field state pending.ForgeState
|
||||||
---@field labels? string[]
|
---@field labels? string[]
|
||||||
---@field fetched_at string
|
---@field fetched_at string
|
||||||
|
|
||||||
|
---@class pending.ForgeFetchError
|
||||||
|
---@field kind 'not_found'|'auth'|'network'
|
||||||
|
|
||||||
---@class pending.ForgeBackend
|
---@class pending.ForgeBackend
|
||||||
---@field name string
|
---@field name string
|
||||||
---@field shorthand string
|
---@field shorthand string
|
||||||
---@field default_host string
|
---@field default_host string
|
||||||
---@field cli string
|
---@field cli string
|
||||||
---@field auth_cmd string
|
---@field auth_cmd string
|
||||||
|
---@field auth_status_args string[]
|
||||||
---@field default_icon string
|
---@field default_icon string
|
||||||
---@field default_issue_format string
|
---@field default_issue_format string
|
||||||
---@field _warned boolean
|
---@field _auth? pending.ForgeAuthStatus
|
||||||
---@field parse_url fun(self: pending.ForgeBackend, url: string): pending.ForgeRef?
|
---@field parse_url fun(self: pending.ForgeBackend, url: string): pending.ForgeRef?
|
||||||
---@field api_args fun(self: pending.ForgeBackend, ref: pending.ForgeRef): string[]
|
---@field api_args fun(self: pending.ForgeBackend, ref: pending.ForgeRef): string[]
|
||||||
---@field parse_state fun(self: pending.ForgeBackend, decoded: table): 'open'|'closed'|'merged'
|
---@field parse_state fun(self: pending.ForgeBackend, decoded: table): pending.ForgeState
|
||||||
|
|
||||||
---@class pending.forge
|
---@class pending.forge
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
@ -49,7 +57,7 @@ local _instances_resolved = false
|
||||||
---@param backend pending.ForgeBackend
|
---@param backend pending.ForgeBackend
|
||||||
---@return nil
|
---@return nil
|
||||||
function M.register(backend)
|
function M.register(backend)
|
||||||
backend._warned = false
|
backend._auth = 'unknown'
|
||||||
table.insert(_backends, backend)
|
table.insert(_backends, backend)
|
||||||
_by_name[backend.name] = backend
|
_by_name[backend.name] = backend
|
||||||
_by_shorthand[backend.shorthand] = backend
|
_by_shorthand[backend.shorthand] = backend
|
||||||
|
|
@ -62,6 +70,61 @@ function M.backends()
|
||||||
return _backends
|
return _backends
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@param forge_name string
|
||||||
|
---@return boolean
|
||||||
|
function M.is_configured(forge_name)
|
||||||
|
local raw = vim.g.pending
|
||||||
|
if not raw or not raw.forge then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return raw.forge[forge_name] ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param backend pending.ForgeBackend
|
||||||
|
---@param callback fun(ok: boolean)
|
||||||
|
function M.check_auth(backend, callback)
|
||||||
|
if backend._auth == 'ok' then
|
||||||
|
callback(true)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if backend._auth == 'failed' then
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if vim.fn.executable(backend.cli) == 0 then
|
||||||
|
backend._auth = 'failed'
|
||||||
|
local forge_cfg = config.get().forge or {}
|
||||||
|
if forge_cfg.warn_missing_cli ~= false then
|
||||||
|
log.warn(('%s not found — run `%s`'):format(backend.cli, backend.auth_cmd))
|
||||||
|
end
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
vim.system(backend.auth_status_args, { text = true }, function(result)
|
||||||
|
vim.schedule(function()
|
||||||
|
if result.code == 0 then
|
||||||
|
backend._auth = 'ok'
|
||||||
|
callback(true)
|
||||||
|
else
|
||||||
|
backend._auth = 'failed'
|
||||||
|
local forge_cfg = config.get().forge or {}
|
||||||
|
if forge_cfg.warn_missing_cli ~= false then
|
||||||
|
log.warn(('%s not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd))
|
||||||
|
end
|
||||||
|
callback(false)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function M._reset_instances()
|
||||||
|
_instances_resolved = false
|
||||||
|
_by_shorthand = {}
|
||||||
|
for _, b in ipairs(_backends) do
|
||||||
|
_by_shorthand[b.shorthand] = b
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local function _ensure_instances()
|
local function _ensure_instances()
|
||||||
if _instances_resolved then
|
if _instances_resolved then
|
||||||
return
|
return
|
||||||
|
|
@ -73,26 +136,41 @@ local function _ensure_instances()
|
||||||
for _, inst in ipairs(forge_cfg.instances or {}) do
|
for _, inst in ipairs(forge_cfg.instances or {}) do
|
||||||
_by_host[inst] = backend
|
_by_host[inst] = backend
|
||||||
end
|
end
|
||||||
|
if forge_cfg.shorthand and forge_cfg.shorthand ~= backend.shorthand then
|
||||||
|
_by_shorthand[backend.shorthand] = nil
|
||||||
|
backend.shorthand = forge_cfg.shorthand
|
||||||
|
_by_shorthand[backend.shorthand] = backend
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param token string
|
---@param token string
|
||||||
---@return pending.ForgeRef?
|
---@return pending.ForgeRef?
|
||||||
function M._parse_shorthand(token)
|
function M._parse_shorthand(token)
|
||||||
local prefix, rest = token:match('^(%l%l):(.+)$')
|
_ensure_instances()
|
||||||
if not prefix then
|
local backend, rest
|
||||||
return nil
|
for prefix, b in pairs(_by_shorthand) do
|
||||||
|
local candidate = token:match('^' .. vim.pesc(prefix) .. ':(.+)$')
|
||||||
|
if candidate then
|
||||||
|
backend = b
|
||||||
|
rest = candidate
|
||||||
|
break
|
||||||
|
end
|
||||||
end
|
end
|
||||||
local backend = _by_shorthand[prefix]
|
|
||||||
if not backend then
|
if not backend then
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local owner, repo, number = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)#(%d+)$')
|
local owner, repo, number = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)#(%d+)$')
|
||||||
if not owner then
|
if owner then
|
||||||
return nil
|
|
||||||
end
|
|
||||||
local num = tonumber(number) --[[@as integer]]
|
local num = tonumber(number) --[[@as integer]]
|
||||||
local url = 'https://' .. backend.default_host .. '/' .. owner .. '/' .. repo .. '/issues/' .. num
|
local url = 'https://'
|
||||||
|
.. backend.default_host
|
||||||
|
.. '/'
|
||||||
|
.. owner
|
||||||
|
.. '/'
|
||||||
|
.. repo
|
||||||
|
.. '/issues/'
|
||||||
|
.. num
|
||||||
return {
|
return {
|
||||||
forge = backend.name,
|
forge = backend.name,
|
||||||
owner = owner,
|
owner = owner,
|
||||||
|
|
@ -102,6 +180,18 @@ function M._parse_shorthand(token)
|
||||||
url = url,
|
url = url,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
owner, repo = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)$')
|
||||||
|
if not owner then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
forge = backend.name,
|
||||||
|
owner = owner,
|
||||||
|
repo = repo,
|
||||||
|
type = 'repo',
|
||||||
|
url = 'https://' .. backend.default_host .. '/' .. owner .. '/' .. repo,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
---@param url string
|
---@param url string
|
||||||
---@return pending.ForgeRef?
|
---@return pending.ForgeRef?
|
||||||
|
|
@ -192,7 +282,7 @@ end
|
||||||
---@return string[]
|
---@return string[]
|
||||||
function M._api_args(ref)
|
function M._api_args(ref)
|
||||||
local backend = _by_name[ref.forge]
|
local backend = _by_name[ref.forge]
|
||||||
if not backend then
|
if not backend or not ref.number then
|
||||||
return {}
|
return {}
|
||||||
end
|
end
|
||||||
return backend:api_args(ref)
|
return backend:api_args(ref)
|
||||||
|
|
@ -209,12 +299,15 @@ function M.format_label(ref, cache)
|
||||||
local default_icon = backend and backend.default_icon or ''
|
local default_icon = backend and backend.default_icon or ''
|
||||||
local default_fmt = backend and backend.default_issue_format or '%i %o/%r#%n'
|
local default_fmt = backend and backend.default_issue_format or '%i %o/%r#%n'
|
||||||
local fmt = forge_cfg.issue_format or default_fmt
|
local fmt = forge_cfg.issue_format or default_fmt
|
||||||
|
if ref.type == 'repo' then
|
||||||
|
fmt = fmt:gsub('#?%%n', ''):gsub('%s+$', '')
|
||||||
|
end
|
||||||
local icon = forge_cfg.icon or default_icon
|
local icon = forge_cfg.icon or default_icon
|
||||||
local text = fmt
|
local text = fmt
|
||||||
:gsub('%%i', icon)
|
:gsub('%%i', icon)
|
||||||
:gsub('%%o', ref.owner)
|
:gsub('%%o', ref.owner)
|
||||||
:gsub('%%r', ref.repo)
|
:gsub('%%r', ref.repo)
|
||||||
:gsub('%%n', tostring(ref.number))
|
:gsub('%%n', ref.number and tostring(ref.number) or '')
|
||||||
local hl = 'PendingForge'
|
local hl = 'PendingForge'
|
||||||
if cache then
|
if cache then
|
||||||
if cache.state == 'closed' or cache.state == 'merged' then
|
if cache.state == 'closed' or cache.state == 'merged' then
|
||||||
|
|
@ -225,29 +318,31 @@ function M.format_label(ref, cache)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param ref pending.ForgeRef
|
---@param ref pending.ForgeRef
|
||||||
---@param callback fun(cache: pending.ForgeCache?)
|
---@param callback fun(cache: pending.ForgeCache?, err: pending.ForgeFetchError?)
|
||||||
function M.fetch_metadata(ref, callback)
|
function M.fetch_metadata(ref, callback)
|
||||||
|
if ref.type == 'repo' then
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
local args = M._api_args(ref)
|
local args = M._api_args(ref)
|
||||||
|
|
||||||
vim.system(args, { text = true }, function(result)
|
vim.system(args, { text = true }, function(result)
|
||||||
if result.code ~= 0 or not result.stdout or result.stdout == '' then
|
if result.code ~= 0 or not result.stdout or result.stdout == '' then
|
||||||
vim.schedule(function()
|
local kind = 'network'
|
||||||
local forge_cfg = config.get().forge or {}
|
local stderr = result.stderr or ''
|
||||||
local backend = _by_name[ref.forge]
|
if stderr:find('404') or stderr:find('Not Found') then
|
||||||
if backend and forge_cfg.warn_missing_cli ~= false and not backend._warned then
|
kind = 'not_found'
|
||||||
backend._warned = true
|
elseif stderr:find('401') or stderr:find('403') or stderr:find('auth') then
|
||||||
log.warn(
|
kind = 'auth'
|
||||||
('%s not found or not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd)
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
callback(nil)
|
vim.schedule(function()
|
||||||
|
callback(nil, { kind = kind })
|
||||||
end)
|
end)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local ok, decoded = pcall(vim.json.decode, result.stdout)
|
local ok, decoded = pcall(vim.json.decode, result.stdout)
|
||||||
if not ok or not decoded then
|
if not ok or not decoded then
|
||||||
vim.schedule(function()
|
vim.schedule(function()
|
||||||
callback(nil)
|
callback(nil, { kind = 'network' })
|
||||||
end)
|
end)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -277,23 +372,46 @@ end
|
||||||
|
|
||||||
---@param s pending.Store
|
---@param s pending.Store
|
||||||
function M.refresh(s)
|
function M.refresh(s)
|
||||||
|
local forge_cfg = config.get().forge or {}
|
||||||
|
if not forge_cfg.close then
|
||||||
|
return
|
||||||
|
end
|
||||||
local tasks = s:tasks()
|
local tasks = s:tasks()
|
||||||
local pending_fetches = 0
|
local by_forge = {} ---@type table<string, pending.Task[]>
|
||||||
|
for _, task in ipairs(tasks) do
|
||||||
|
if
|
||||||
|
task.status ~= 'deleted'
|
||||||
|
and task._extra
|
||||||
|
and task._extra._forge_ref
|
||||||
|
and task._extra._forge_ref.type ~= 'repo'
|
||||||
|
then
|
||||||
|
local fname = task._extra._forge_ref.forge
|
||||||
|
if not by_forge[fname] then
|
||||||
|
by_forge[fname] = {}
|
||||||
|
end
|
||||||
|
table.insert(by_forge[fname], task)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local any_work = false
|
||||||
|
for fname, forge_tasks in pairs(by_forge) do
|
||||||
|
if M.is_configured(fname) and _by_name[fname] then
|
||||||
|
any_work = true
|
||||||
|
M.check_auth(_by_name[fname], function(authed)
|
||||||
|
if not authed then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local remaining = #forge_tasks
|
||||||
local any_changed = false
|
local any_changed = false
|
||||||
local any_fetched = false
|
local any_fetched = false
|
||||||
for _, task in ipairs(tasks) do
|
for _, task in ipairs(forge_tasks) do
|
||||||
if task.status ~= 'deleted' and task._extra and task._extra._forge_ref then
|
|
||||||
local ref = task._extra._forge_ref --[[@as pending.ForgeRef]]
|
local ref = task._extra._forge_ref --[[@as pending.ForgeRef]]
|
||||||
pending_fetches = pending_fetches + 1
|
|
||||||
M.fetch_metadata(ref, function(cache)
|
M.fetch_metadata(ref, function(cache)
|
||||||
pending_fetches = pending_fetches - 1
|
remaining = remaining - 1
|
||||||
if cache then
|
if cache then
|
||||||
task._extra._forge_cache = cache
|
task._extra._forge_cache = cache
|
||||||
any_fetched = true
|
any_fetched = true
|
||||||
local forge_cfg = config.get().forge or {}
|
|
||||||
if
|
if
|
||||||
forge_cfg.auto_close
|
(cache.state == 'closed' or cache.state == 'merged')
|
||||||
and (cache.state == 'closed' or cache.state == 'merged')
|
|
||||||
and (task.status == 'pending' or task.status == 'wip' or task.status == 'blocked')
|
and (task.status == 'pending' or task.status == 'wip' or task.status == 'blocked')
|
||||||
then
|
then
|
||||||
task.status = 'done'
|
task.status = 'done'
|
||||||
|
|
@ -307,7 +425,7 @@ function M.refresh(s)
|
||||||
fetched_at = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]],
|
fetched_at = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]],
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
if pending_fetches == 0 then
|
if remaining == 0 then
|
||||||
if any_changed then
|
if any_changed then
|
||||||
s:save()
|
s:save()
|
||||||
end
|
end
|
||||||
|
|
@ -322,37 +440,65 @@ function M.refresh(s)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
if pending_fetches == 0 then
|
end
|
||||||
|
if not any_work then
|
||||||
log.info('No linked tasks to refresh.')
|
log.info('No linked tasks to refresh.')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param opts {name: string, shorthand: string, default_host: string, cli?: string, auth_cmd?: string, default_icon?: string, default_issue_format?: string}
|
---@param refs pending.ForgeRef[]
|
||||||
|
function M.validate_refs(refs)
|
||||||
|
local by_forge = {} ---@type table<string, pending.ForgeRef[]>
|
||||||
|
for _, ref in ipairs(refs) do
|
||||||
|
if ref.type == 'repo' then
|
||||||
|
goto skip_ref
|
||||||
|
end
|
||||||
|
local fname = ref.forge
|
||||||
|
if not by_forge[fname] then
|
||||||
|
by_forge[fname] = {}
|
||||||
|
end
|
||||||
|
table.insert(by_forge[fname], ref)
|
||||||
|
::skip_ref::
|
||||||
|
end
|
||||||
|
for fname, forge_refs in pairs(by_forge) do
|
||||||
|
if not M.is_configured(fname) or not _by_name[fname] then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
M.check_auth(_by_name[fname], function(authed)
|
||||||
|
if not authed then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
for _, ref in ipairs(forge_refs) do
|
||||||
|
M.fetch_metadata(ref, function(_, err)
|
||||||
|
if err and err.kind == 'not_found' then
|
||||||
|
log.warn(('%s:%s/%s#%d not found'):format(ref.forge, ref.owner, ref.repo, ref.number))
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
::continue::
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param opts {name: string, shorthand: string, default_host: string, cli?: string, auth_cmd?: string, auth_status_args?: string[], default_icon?: string, default_issue_format?: string}
|
||||||
---@return pending.ForgeBackend
|
---@return pending.ForgeBackend
|
||||||
function M.gitea_backend(opts)
|
function M.gitea_forge(opts)
|
||||||
return {
|
return {
|
||||||
name = opts.name,
|
name = opts.name,
|
||||||
shorthand = opts.shorthand,
|
shorthand = opts.shorthand,
|
||||||
default_host = opts.default_host,
|
default_host = opts.default_host,
|
||||||
cli = opts.cli or 'tea',
|
cli = opts.cli or 'tea',
|
||||||
auth_cmd = opts.auth_cmd or 'tea login add',
|
auth_cmd = opts.auth_cmd or 'tea login add',
|
||||||
|
auth_status_args = opts.auth_status_args or { opts.cli or 'tea', 'login', 'list' },
|
||||||
default_icon = opts.default_icon or '',
|
default_icon = opts.default_icon or '',
|
||||||
default_issue_format = opts.default_issue_format or '%i %o/%r#%n',
|
default_issue_format = opts.default_issue_format or '%i %o/%r#%n',
|
||||||
_warned = false,
|
|
||||||
parse_url = function(self, url)
|
parse_url = function(self, url)
|
||||||
_ensure_instances()
|
_ensure_instances()
|
||||||
local host, owner, repo, kind, number =
|
local host, owner, repo, kind, number =
|
||||||
url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$')
|
url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$')
|
||||||
if not host then
|
if host and (kind == 'issues' or kind == 'pulls') and _by_host[host] == self then
|
||||||
return nil
|
|
||||||
end
|
|
||||||
if kind ~= 'issues' and kind ~= 'pulls' then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
if _by_host[host] ~= self then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
local num = tonumber(number) --[[@as integer]]
|
local num = tonumber(number) --[[@as integer]]
|
||||||
local ref_type = kind == 'pulls' and 'pull_request' or 'issue'
|
local ref_type = kind == 'pulls' and 'pull_request' or 'issue'
|
||||||
return {
|
return {
|
||||||
|
|
@ -363,6 +509,18 @@ function M.gitea_backend(opts)
|
||||||
number = num,
|
number = num,
|
||||||
url = url,
|
url = url,
|
||||||
}
|
}
|
||||||
|
end
|
||||||
|
host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$')
|
||||||
|
if host and _by_host[host] == self then
|
||||||
|
return {
|
||||||
|
forge = self.name,
|
||||||
|
owner = owner,
|
||||||
|
repo = repo,
|
||||||
|
type = 'repo',
|
||||||
|
url = url,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return nil
|
||||||
end,
|
end,
|
||||||
api_args = function(self, ref)
|
api_args = function(self, ref)
|
||||||
local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues'
|
local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues'
|
||||||
|
|
@ -387,22 +545,14 @@ M.register({
|
||||||
default_host = 'github.com',
|
default_host = 'github.com',
|
||||||
cli = 'gh',
|
cli = 'gh',
|
||||||
auth_cmd = 'gh auth login',
|
auth_cmd = 'gh auth login',
|
||||||
|
auth_status_args = { 'gh', 'auth', 'status' },
|
||||||
default_icon = '',
|
default_icon = '',
|
||||||
default_issue_format = '%i %o/%r#%n',
|
default_issue_format = '%i %o/%r#%n',
|
||||||
_warned = false,
|
|
||||||
parse_url = function(self, url)
|
parse_url = function(self, url)
|
||||||
_ensure_instances()
|
_ensure_instances()
|
||||||
local host, owner, repo, kind, number =
|
local host, owner, repo, kind, number =
|
||||||
url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$')
|
url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$')
|
||||||
if not host then
|
if host and (kind == 'issues' or kind == 'pull') and _by_host[host] == self then
|
||||||
return nil
|
|
||||||
end
|
|
||||||
if kind ~= 'issues' and kind ~= 'pull' then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
if _by_host[host] ~= self then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
local num = tonumber(number) --[[@as integer]]
|
local num = tonumber(number) --[[@as integer]]
|
||||||
local ref_type = kind == 'pull' and 'pull_request' or 'issue'
|
local ref_type = kind == 'pull' and 'pull_request' or 'issue'
|
||||||
return {
|
return {
|
||||||
|
|
@ -413,6 +563,18 @@ M.register({
|
||||||
number = num,
|
number = num,
|
||||||
url = url,
|
url = url,
|
||||||
}
|
}
|
||||||
|
end
|
||||||
|
host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$')
|
||||||
|
if host and _by_host[host] == self then
|
||||||
|
return {
|
||||||
|
forge = 'github',
|
||||||
|
owner = owner,
|
||||||
|
repo = repo,
|
||||||
|
type = 'repo',
|
||||||
|
url = url,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return nil
|
||||||
end,
|
end,
|
||||||
api_args = function(_, ref)
|
api_args = function(_, ref)
|
||||||
return {
|
return {
|
||||||
|
|
@ -437,25 +599,15 @@ M.register({
|
||||||
default_host = 'gitlab.com',
|
default_host = 'gitlab.com',
|
||||||
cli = 'glab',
|
cli = 'glab',
|
||||||
auth_cmd = 'glab auth login',
|
auth_cmd = 'glab auth login',
|
||||||
|
auth_status_args = { 'glab', 'auth', 'status' },
|
||||||
default_icon = '',
|
default_icon = '',
|
||||||
default_issue_format = '%i %o/%r#%n',
|
default_issue_format = '%i %o/%r#%n',
|
||||||
_warned = false,
|
|
||||||
parse_url = function(self, url)
|
parse_url = function(self, url)
|
||||||
_ensure_instances()
|
_ensure_instances()
|
||||||
local host, path, kind, number = url:match('^https?://([^/]+)/(.+)/%-/([%w_]+)/(%d+)$')
|
local host, path, kind, number = url:match('^https?://([^/]+)/(.+)/%-/([%w_]+)/(%d+)$')
|
||||||
if not host then
|
if host and (kind == 'issues' or kind == 'merge_requests') and _by_host[host] == self then
|
||||||
return nil
|
|
||||||
end
|
|
||||||
if kind ~= 'issues' and kind ~= 'merge_requests' then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
if _by_host[host] ~= self then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
local owner, repo = path:match('^(.+)/([^/]+)$')
|
local owner, repo = path:match('^(.+)/([^/]+)$')
|
||||||
if not owner then
|
if owner then
|
||||||
return nil
|
|
||||||
end
|
|
||||||
local num = tonumber(number) --[[@as integer]]
|
local num = tonumber(number) --[[@as integer]]
|
||||||
local ref_type = kind == 'merge_requests' and 'merge_request' or 'issue'
|
local ref_type = kind == 'merge_requests' and 'merge_request' or 'issue'
|
||||||
return {
|
return {
|
||||||
|
|
@ -466,6 +618,25 @@ M.register({
|
||||||
number = num,
|
number = num,
|
||||||
url = url,
|
url = url,
|
||||||
}
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
host, path = url:match('^https?://([^/]+)/(.+)$')
|
||||||
|
if host and _by_host[host] == self then
|
||||||
|
local trimmed = path:gsub('/$', '')
|
||||||
|
if not trimmed:find('/%-/') then
|
||||||
|
local owner, repo = trimmed:match('^(.+)/([^/]+)$')
|
||||||
|
if owner then
|
||||||
|
return {
|
||||||
|
forge = 'gitlab',
|
||||||
|
owner = owner,
|
||||||
|
repo = repo,
|
||||||
|
type = 'repo',
|
||||||
|
url = url,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
end,
|
end,
|
||||||
api_args = function(_, ref)
|
api_args = function(_, ref)
|
||||||
local encoded = (ref.owner .. '/' .. ref.repo):gsub('/', '%%2F')
|
local encoded = (ref.owner .. '/' .. ref.repo):gsub('/', '%%2F')
|
||||||
|
|
@ -492,22 +663,14 @@ M.register({
|
||||||
default_host = 'codeberg.org',
|
default_host = 'codeberg.org',
|
||||||
cli = 'tea',
|
cli = 'tea',
|
||||||
auth_cmd = 'tea login add',
|
auth_cmd = 'tea login add',
|
||||||
|
auth_status_args = { 'tea', 'login', 'list' },
|
||||||
default_icon = '',
|
default_icon = '',
|
||||||
default_issue_format = '%i %o/%r#%n',
|
default_issue_format = '%i %o/%r#%n',
|
||||||
_warned = false,
|
|
||||||
parse_url = function(self, url)
|
parse_url = function(self, url)
|
||||||
_ensure_instances()
|
_ensure_instances()
|
||||||
local host, owner, repo, kind, number =
|
local host, owner, repo, kind, number =
|
||||||
url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$')
|
url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$')
|
||||||
if not host then
|
if host and (kind == 'issues' or kind == 'pulls') and _by_host[host] == self then
|
||||||
return nil
|
|
||||||
end
|
|
||||||
if kind ~= 'issues' and kind ~= 'pulls' then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
if _by_host[host] ~= self then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
local num = tonumber(number) --[[@as integer]]
|
local num = tonumber(number) --[[@as integer]]
|
||||||
local ref_type = kind == 'pulls' and 'pull_request' or 'issue'
|
local ref_type = kind == 'pulls' and 'pull_request' or 'issue'
|
||||||
return {
|
return {
|
||||||
|
|
@ -518,6 +681,18 @@ M.register({
|
||||||
number = num,
|
number = num,
|
||||||
url = url,
|
url = url,
|
||||||
}
|
}
|
||||||
|
end
|
||||||
|
host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$')
|
||||||
|
if host and _by_host[host] == self then
|
||||||
|
return {
|
||||||
|
forge = 'codeberg',
|
||||||
|
owner = owner,
|
||||||
|
repo = repo,
|
||||||
|
type = 'repo',
|
||||||
|
url = url,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return nil
|
||||||
end,
|
end,
|
||||||
api_args = function(_, ref)
|
api_args = function(_, ref)
|
||||||
local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues'
|
local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues'
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,9 @@ function M.check()
|
||||||
vim.health.start('pending.nvim: forge')
|
vim.health.start('pending.nvim: forge')
|
||||||
local forge = require('pending.forge')
|
local forge = require('pending.forge')
|
||||||
for _, backend in ipairs(forge.backends()) do
|
for _, backend in ipairs(forge.backends()) do
|
||||||
if vim.fn.executable(backend.cli) == 1 then
|
if not forge.is_configured(backend.name) then
|
||||||
|
vim.health.info(('%s: not configured (skipped)'):format(backend.name))
|
||||||
|
elseif vim.fn.executable(backend.cli) == 1 then
|
||||||
vim.health.ok(('%s found'):format(backend.cli))
|
vim.health.ok(('%s found'):format(backend.cli))
|
||||||
else
|
else
|
||||||
vim.health.warn(('%s not found — run `%s`'):format(backend.cli, backend.auth_cmd))
|
vim.health.warn(('%s not found — run `%s`'):format(backend.cli, backend.auth_cmd))
|
||||||
|
|
@ -57,7 +59,7 @@ function M.check()
|
||||||
end
|
end
|
||||||
|
|
||||||
local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
|
local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
|
||||||
if #sync_paths == 0 then
|
if #sync_paths == 0 and vim.tbl_isempty(require('pending').registered_backends()) then
|
||||||
vim.health.info('No sync backends found')
|
vim.health.info('No sync backends found')
|
||||||
else
|
else
|
||||||
for _, path in ipairs(sync_paths) do
|
for _, path in ipairs(sync_paths) do
|
||||||
|
|
@ -68,6 +70,12 @@ function M.check()
|
||||||
backend.health()
|
backend.health()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
for rname, rbackend in pairs(require('pending').registered_backends()) do
|
||||||
|
if type(rbackend.health) == 'function' then
|
||||||
|
vim.health.start('pending.nvim: sync/' .. rname)
|
||||||
|
rbackend.health()
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ function M._recompute_counts()
|
||||||
local today_str = os.date('%Y-%m-%d') --[[@as string]]
|
local today_str = os.date('%Y-%m-%d') --[[@as string]]
|
||||||
|
|
||||||
for _, task in ipairs(get_store():active_tasks()) do
|
for _, task in ipairs(get_store():active_tasks()) do
|
||||||
if task.status ~= 'done' and task.status ~= 'deleted' then
|
if task.status ~= 'done' and task.status ~= 'deleted' and task.status ~= 'cancelled' then
|
||||||
pending = pending + 1
|
pending = pending + 1
|
||||||
if task.priority > 0 then
|
if task.priority > 0 then
|
||||||
priority = priority + 1
|
priority = priority + 1
|
||||||
|
|
@ -173,6 +173,11 @@ local function compute_hidden_ids(tasks, predicates)
|
||||||
visible = false
|
visible = false
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
|
elseif pred == 'cancelled' then
|
||||||
|
if task.status ~= 'cancelled' then
|
||||||
|
visible = false
|
||||||
|
break
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if not visible then
|
if not visible then
|
||||||
|
|
@ -368,6 +373,9 @@ function M._setup_buf_mappings(bufnr)
|
||||||
blocked = function()
|
blocked = function()
|
||||||
M.toggle_status('blocked')
|
M.toggle_status('blocked')
|
||||||
end,
|
end,
|
||||||
|
cancelled = function()
|
||||||
|
M.toggle_status('cancelled')
|
||||||
|
end,
|
||||||
priority_up = function()
|
priority_up = function()
|
||||||
M.increment_priority()
|
M.increment_priority()
|
||||||
end,
|
end,
|
||||||
|
|
@ -393,6 +401,9 @@ function M._setup_buf_mappings(bufnr)
|
||||||
open_line_above = function()
|
open_line_above = function()
|
||||||
buffer.open_line(true)
|
buffer.open_line(true)
|
||||||
end,
|
end,
|
||||||
|
edit_notes = function()
|
||||||
|
M.open_detail()
|
||||||
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, fn in pairs(actions) do
|
for name, fn in pairs(actions) do
|
||||||
|
|
@ -402,6 +413,30 @@ function M._setup_buf_mappings(bufnr)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@type table<string, fun()>
|
||||||
|
local visual_actions = {
|
||||||
|
priority_up_visual = function()
|
||||||
|
M.increment_priority_visual()
|
||||||
|
end,
|
||||||
|
priority_down_visual = function()
|
||||||
|
M.decrement_priority_visual()
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, fn in pairs(visual_actions) do
|
||||||
|
local key = km[name]
|
||||||
|
if key and key ~= false then
|
||||||
|
vim.keymap.set('x', key --[[@as string]], function()
|
||||||
|
vim.api.nvim_feedkeys(
|
||||||
|
vim.api.nvim_replace_termcodes('<Esc>', true, false, true),
|
||||||
|
'nx',
|
||||||
|
false
|
||||||
|
)
|
||||||
|
fn()
|
||||||
|
end, opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local textobj = require('pending.textobj')
|
local textobj = require('pending.textobj')
|
||||||
|
|
||||||
---@type table<string, { modes: string[], fn: fun(count: integer), visual_fn?: fun(count: integer) }>
|
---@type table<string, { modes: string[], fn: fun(count: integer), visual_fn?: fun(count: integer) }>
|
||||||
|
|
@ -489,9 +524,13 @@ function M._on_write(bufnr)
|
||||||
if #stack > UNDO_MAX then
|
if #stack > UNDO_MAX then
|
||||||
table.remove(stack, 1)
|
table.remove(stack, 1)
|
||||||
end
|
end
|
||||||
diff.apply(lines, s, hidden)
|
local new_refs = diff.apply(lines, s, hidden)
|
||||||
M._recompute_counts()
|
M._recompute_counts()
|
||||||
buffer.render(bufnr)
|
buffer.render(bufnr)
|
||||||
|
if new_refs and #new_refs > 0 then
|
||||||
|
local forge = require('pending.forge')
|
||||||
|
forge.validate_refs(new_refs)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return nil
|
---@return nil
|
||||||
|
|
@ -520,7 +559,8 @@ function M.toggle_complete()
|
||||||
if not require_saved() then
|
if not require_saved() then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local row = vim.api.nvim_win_get_cursor(0)[1]
|
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||||
|
local row, col = cursor[1], cursor[2]
|
||||||
local meta = buffer.meta()
|
local meta = buffer.meta()
|
||||||
if not meta[row] or meta[row].type ~= 'task' then
|
if not meta[row] or meta[row].type ~= 'task' then
|
||||||
return
|
return
|
||||||
|
|
@ -554,12 +594,26 @@ function M.toggle_complete()
|
||||||
end
|
end
|
||||||
_save_and_notify()
|
_save_and_notify()
|
||||||
buffer.render(bufnr)
|
buffer.render(bufnr)
|
||||||
for lnum, m in ipairs(buffer.meta()) do
|
local new_meta = buffer.meta()
|
||||||
if m.id == id then
|
local total = #new_meta
|
||||||
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
|
local target = math.min(row, total)
|
||||||
break
|
if new_meta[target] and new_meta[target].type == 'task' then
|
||||||
|
vim.api.nvim_win_set_cursor(0, { target, col })
|
||||||
|
else
|
||||||
|
for r = target, total do
|
||||||
|
if new_meta[r] and new_meta[r].type == 'task' then
|
||||||
|
vim.api.nvim_win_set_cursor(0, { r, col })
|
||||||
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
for r = target, 1, -1 do
|
||||||
|
if new_meta[r] and new_meta[r].type == 'task' then
|
||||||
|
vim.api.nvim_win_set_cursor(0, { r, col })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
vim.api.nvim_win_set_cursor(0, { target, col })
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param id_str? string
|
---@param id_str? string
|
||||||
|
|
@ -630,7 +684,8 @@ function M.toggle_priority()
|
||||||
if not require_saved() then
|
if not require_saved() then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local row = vim.api.nvim_win_get_cursor(0)[1]
|
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||||
|
local row, col = cursor[1], cursor[2]
|
||||||
local meta = buffer.meta()
|
local meta = buffer.meta()
|
||||||
if not meta[row] or meta[row].type ~= 'task' then
|
if not meta[row] or meta[row].type ~= 'task' then
|
||||||
return
|
return
|
||||||
|
|
@ -651,7 +706,7 @@ function M.toggle_priority()
|
||||||
buffer.render(bufnr)
|
buffer.render(bufnr)
|
||||||
for lnum, m in ipairs(buffer.meta()) do
|
for lnum, m in ipairs(buffer.meta()) do
|
||||||
if m.id == id then
|
if m.id == id then
|
||||||
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
|
vim.api.nvim_win_set_cursor(0, { lnum, col })
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -667,7 +722,8 @@ local function adjust_priority(delta)
|
||||||
if not require_saved() then
|
if not require_saved() then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local row = vim.api.nvim_win_get_cursor(0)[1]
|
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||||
|
local row, col = cursor[1], cursor[2]
|
||||||
local meta = buffer.meta()
|
local meta = buffer.meta()
|
||||||
if not meta[row] or meta[row].type ~= 'task' then
|
if not meta[row] or meta[row].type ~= 'task' then
|
||||||
return
|
return
|
||||||
|
|
@ -691,7 +747,7 @@ local function adjust_priority(delta)
|
||||||
buffer.render(bufnr)
|
buffer.render(bufnr)
|
||||||
for lnum, m in ipairs(buffer.meta()) do
|
for lnum, m in ipairs(buffer.meta()) do
|
||||||
if m.id == id then
|
if m.id == id then
|
||||||
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
|
vim.api.nvim_win_set_cursor(0, { lnum, col })
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -707,6 +763,53 @@ function M.decrement_priority()
|
||||||
adjust_priority(-1)
|
adjust_priority(-1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@param delta integer
|
||||||
|
---@return nil
|
||||||
|
local function adjust_priority_visual(delta)
|
||||||
|
local bufnr = buffer.bufnr()
|
||||||
|
if not bufnr then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not require_saved() then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local start_row = vim.fn.line("'<")
|
||||||
|
local end_row = vim.fn.line("'>")
|
||||||
|
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||||
|
local meta = buffer.meta()
|
||||||
|
local s = get_store()
|
||||||
|
local max = require('pending.config').get().max_priority or 3
|
||||||
|
local changed = false
|
||||||
|
for row = start_row, end_row do
|
||||||
|
if meta[row] and meta[row].type == 'task' and meta[row].id then
|
||||||
|
local task = s:get(meta[row].id)
|
||||||
|
if task then
|
||||||
|
local new_priority = math.max(0, math.min(max, task.priority + delta))
|
||||||
|
if new_priority ~= task.priority then
|
||||||
|
s:update(meta[row].id, { priority = new_priority })
|
||||||
|
changed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not changed then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
_save_and_notify()
|
||||||
|
buffer.render(bufnr)
|
||||||
|
pcall(vim.api.nvim_win_set_cursor, 0, cursor)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
|
function M.increment_priority_visual()
|
||||||
|
adjust_priority_visual(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
|
function M.decrement_priority_visual()
|
||||||
|
adjust_priority_visual(-1)
|
||||||
|
end
|
||||||
|
|
||||||
---@return nil
|
---@return nil
|
||||||
function M.prompt_date()
|
function M.prompt_date()
|
||||||
local bufnr = buffer.bufnr()
|
local bufnr = buffer.bufnr()
|
||||||
|
|
@ -748,9 +851,48 @@ function M.prompt_date()
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param target_status 'wip'|'blocked'
|
---@param target_status 'wip'|'blocked'|'cancelled'
|
||||||
---@return nil
|
---@return nil
|
||||||
function M.toggle_status(target_status)
|
function M.toggle_status(target_status)
|
||||||
|
local bufnr = buffer.bufnr()
|
||||||
|
if not bufnr then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not require_saved() then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||||
|
local row, col = cursor[1], cursor[2]
|
||||||
|
local meta = buffer.meta()
|
||||||
|
if not meta[row] or meta[row].type ~= 'task' then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local id = meta[row].id
|
||||||
|
if not id then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local s = get_store()
|
||||||
|
local task = s:get(id)
|
||||||
|
if not task then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if task.status == target_status then
|
||||||
|
s:update(id, { status = 'pending', ['end'] = vim.NIL })
|
||||||
|
else
|
||||||
|
s:update(id, { status = target_status })
|
||||||
|
end
|
||||||
|
_save_and_notify()
|
||||||
|
buffer.render(bufnr)
|
||||||
|
for lnum, m in ipairs(buffer.meta()) do
|
||||||
|
if m.id == id then
|
||||||
|
vim.api.nvim_win_set_cursor(0, { lnum, col })
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
|
function M.open_detail()
|
||||||
local bufnr = buffer.bufnr()
|
local bufnr = buffer.bufnr()
|
||||||
if not bufnr then
|
if not bufnr then
|
||||||
return
|
return
|
||||||
|
|
@ -767,24 +909,26 @@ function M.toggle_status(target_status)
|
||||||
if not id then
|
if not id then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local s = get_store()
|
|
||||||
local task = s:get(id)
|
local detail_bufnr = buffer.open_detail(id)
|
||||||
if not task then
|
if not detail_bufnr then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if task.status == target_status then
|
|
||||||
s:update(id, { status = 'pending' })
|
local group = vim.api.nvim_create_augroup('PendingDetail', { clear = true })
|
||||||
else
|
vim.api.nvim_create_autocmd('BufWriteCmd', {
|
||||||
s:update(id, { status = target_status })
|
group = group,
|
||||||
end
|
buffer = detail_bufnr,
|
||||||
_save_and_notify()
|
callback = function()
|
||||||
buffer.render(bufnr)
|
buffer.save_detail()
|
||||||
for lnum, m in ipairs(buffer.meta()) do
|
end,
|
||||||
if m.id == id then
|
})
|
||||||
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
|
|
||||||
break
|
local km = require('pending.config').get().keymaps
|
||||||
end
|
vim.keymap.set('n', km.close or 'q', function()
|
||||||
end
|
vim.api.nvim_del_augroup_by_name('PendingDetail')
|
||||||
|
buffer.close_detail()
|
||||||
|
end, { buffer = detail_bufnr })
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param direction 'up'|'down'
|
---@param direction 'up'|'down'
|
||||||
|
|
@ -797,7 +941,8 @@ function M.move_task(direction)
|
||||||
if not require_saved() then
|
if not require_saved() then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local row = vim.api.nvim_win_get_cursor(0)[1]
|
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||||
|
local row, col = cursor[1], cursor[2]
|
||||||
local meta = buffer.meta()
|
local meta = buffer.meta()
|
||||||
if not meta[row] or meta[row].type ~= 'task' then
|
if not meta[row] or meta[row].type ~= 'task' then
|
||||||
return
|
return
|
||||||
|
|
@ -872,7 +1017,7 @@ function M.move_task(direction)
|
||||||
|
|
||||||
for lnum, m in ipairs(buffer.meta()) do
|
for lnum, m in ipairs(buffer.meta()) do
|
||||||
if m.id == id then
|
if m.id == id then
|
||||||
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
|
vim.api.nvim_win_set_cursor(0, { lnum, col })
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -980,10 +1125,10 @@ function M.add(text)
|
||||||
end
|
end
|
||||||
s:add({
|
s:add({
|
||||||
description = description,
|
description = description,
|
||||||
category = metadata.cat,
|
category = metadata.category,
|
||||||
due = metadata.due,
|
due = metadata.due,
|
||||||
recur = metadata.rec,
|
recur = metadata.recur,
|
||||||
recur_mode = metadata.rec_mode,
|
recur_mode = metadata.recur_mode,
|
||||||
priority = metadata.priority,
|
priority = metadata.priority,
|
||||||
})
|
})
|
||||||
_save_and_notify()
|
_save_and_notify()
|
||||||
|
|
@ -994,12 +1139,62 @@ function M.add(text)
|
||||||
log.info('Task added: ' .. description)
|
log.info('Task added: ' .. description)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@class pending.SyncBackend
|
||||||
|
---@field name string
|
||||||
|
---@field auth? fun(sub_action?: string): nil
|
||||||
|
---@field push? fun(): nil
|
||||||
|
---@field pull? fun(): nil
|
||||||
|
---@field sync? fun(): nil
|
||||||
|
---@field health? fun(): nil
|
||||||
|
|
||||||
|
---@type table<string, pending.SyncBackend>
|
||||||
|
local _registered_backends = {}
|
||||||
|
|
||||||
---@type string[]?
|
---@type string[]?
|
||||||
local _sync_backends = nil
|
local _sync_backends = nil
|
||||||
|
|
||||||
---@type table<string, true>?
|
---@type table<string, true>?
|
||||||
local _sync_backend_set = nil
|
local _sync_backend_set = nil
|
||||||
|
|
||||||
|
---@param name string
|
||||||
|
---@return pending.SyncBackend?
|
||||||
|
function M.resolve_backend(name)
|
||||||
|
if _registered_backends[name] then
|
||||||
|
return _registered_backends[name]
|
||||||
|
end
|
||||||
|
local ok, mod = pcall(require, 'pending.sync.' .. name)
|
||||||
|
if ok and type(mod) == 'table' and mod.name then
|
||||||
|
return mod
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param backend pending.SyncBackend
|
||||||
|
---@return nil
|
||||||
|
function M.register_backend(backend)
|
||||||
|
if type(backend) ~= 'table' or type(backend.name) ~= 'string' or backend.name == '' then
|
||||||
|
log.error('register_backend: backend must have a non-empty `name` field')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local builtin_ok, builtin = pcall(require, 'pending.sync.' .. backend.name)
|
||||||
|
if builtin_ok and type(builtin) == 'table' and builtin.name then
|
||||||
|
log.error('register_backend: backend `' .. backend.name .. '` already exists as a built-in')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if _registered_backends[backend.name] then
|
||||||
|
log.error('register_backend: backend `' .. backend.name .. '` is already registered')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
_registered_backends[backend.name] = backend
|
||||||
|
_sync_backends = nil
|
||||||
|
_sync_backend_set = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return table<string, pending.SyncBackend>
|
||||||
|
function M.registered_backends()
|
||||||
|
return _registered_backends
|
||||||
|
end
|
||||||
|
|
||||||
---@return string[], table<string, true>
|
---@return string[], table<string, true>
|
||||||
local function discover_backends()
|
local function discover_backends()
|
||||||
if _sync_backends then
|
if _sync_backends then
|
||||||
|
|
@ -1016,6 +1211,12 @@ local function discover_backends()
|
||||||
_sync_backend_set[mod.name] = true
|
_sync_backend_set[mod.name] = true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
for name, _ in pairs(_registered_backends) do
|
||||||
|
if not _sync_backend_set[name] then
|
||||||
|
table.insert(_sync_backends, name)
|
||||||
|
_sync_backend_set[name] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
table.sort(_sync_backends)
|
table.sort(_sync_backends)
|
||||||
return _sync_backends, _sync_backend_set
|
return _sync_backends, _sync_backend_set
|
||||||
end
|
end
|
||||||
|
|
@ -1024,8 +1225,8 @@ end
|
||||||
---@param action? string
|
---@param action? string
|
||||||
---@return nil
|
---@return nil
|
||||||
local function run_sync(backend_name, action)
|
local function run_sync(backend_name, action)
|
||||||
local ok, backend = pcall(require, 'pending.sync.' .. backend_name)
|
local backend = M.resolve_backend(backend_name)
|
||||||
if not ok then
|
if not backend then
|
||||||
log.error('Unknown sync backend: ' .. backend_name)
|
log.error('Unknown sync backend: ' .. backend_name)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -1082,7 +1283,10 @@ function M.archive(arg)
|
||||||
log.debug(('archive: days=%d cutoff=%s total_tasks=%d'):format(days, cutoff, #tasks))
|
log.debug(('archive: days=%d cutoff=%s total_tasks=%d'):format(days, cutoff, #tasks))
|
||||||
local count = 0
|
local count = 0
|
||||||
for _, task in ipairs(tasks) do
|
for _, task in ipairs(tasks) do
|
||||||
if (task.status == 'done' or task.status == 'deleted') and task['end'] then
|
if
|
||||||
|
(task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled')
|
||||||
|
and task['end']
|
||||||
|
then
|
||||||
if task['end'] < cutoff then
|
if task['end'] < cutoff then
|
||||||
count = count + 1
|
count = count + 1
|
||||||
end
|
end
|
||||||
|
|
@ -1103,7 +1307,10 @@ function M.archive(arg)
|
||||||
function()
|
function()
|
||||||
local kept = {}
|
local kept = {}
|
||||||
for _, task in ipairs(tasks) do
|
for _, task in ipairs(tasks) do
|
||||||
if (task.status == 'done' or task.status == 'deleted') and task['end'] then
|
if
|
||||||
|
(task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled')
|
||||||
|
and task['end']
|
||||||
|
then
|
||||||
if task['end'] < cutoff then
|
if task['end'] < cutoff then
|
||||||
goto skip
|
goto skip
|
||||||
end
|
end
|
||||||
|
|
@ -1182,6 +1389,7 @@ end
|
||||||
local function parse_edit_token(token)
|
local function parse_edit_token(token)
|
||||||
local recur = require('pending.recur')
|
local recur = require('pending.recur')
|
||||||
local cfg = require('pending.config').get()
|
local cfg = require('pending.config').get()
|
||||||
|
local ck = cfg.category_syntax or 'cat'
|
||||||
local dk = cfg.date_syntax or 'due'
|
local dk = cfg.date_syntax or 'due'
|
||||||
local rk = cfg.recur_syntax or 'rec'
|
local rk = cfg.recur_syntax or 'rec'
|
||||||
|
|
||||||
|
|
@ -1197,7 +1405,7 @@ local function parse_edit_token(token)
|
||||||
if token == '-due' or token == '-' .. dk then
|
if token == '-due' or token == '-' .. dk then
|
||||||
return 'due', vim.NIL, nil
|
return 'due', vim.NIL, nil
|
||||||
end
|
end
|
||||||
if token == '-cat' then
|
if token == '-' .. ck then
|
||||||
return 'category', vim.NIL, nil
|
return 'category', vim.NIL, nil
|
||||||
end
|
end
|
||||||
if token == '-rec' or token == '-' .. rk then
|
if token == '-rec' or token == '-' .. rk then
|
||||||
|
|
@ -1219,7 +1427,7 @@ local function parse_edit_token(token)
|
||||||
'Invalid date: ' .. due_val .. '. Use YYYY-MM-DD, today, tomorrow, +Nd, weekday names, etc.'
|
'Invalid date: ' .. due_val .. '. Use YYYY-MM-DD, today, tomorrow, +Nd, weekday names, etc.'
|
||||||
end
|
end
|
||||||
|
|
||||||
local cat_val = token:match('^cat:(.+)$')
|
local cat_val = token:match('^' .. vim.pesc(ck) .. ':(.+)$')
|
||||||
if cat_val then
|
if cat_val then
|
||||||
return 'category', cat_val, nil
|
return 'category', cat_val, nil
|
||||||
end
|
end
|
||||||
|
|
@ -1244,11 +1452,15 @@ local function parse_edit_token(token)
|
||||||
.. token
|
.. token
|
||||||
.. '. Valid: '
|
.. '. Valid: '
|
||||||
.. dk
|
.. dk
|
||||||
.. ':<date>, cat:<name>, '
|
.. ':<date>, '
|
||||||
|
.. ck
|
||||||
|
.. ':<name>, '
|
||||||
.. rk
|
.. rk
|
||||||
.. ':<pattern>, +!, -!, -'
|
.. ':<pattern>, +!, -!, -'
|
||||||
.. dk
|
.. dk
|
||||||
.. ', -cat, -'
|
.. ', -'
|
||||||
|
.. ck
|
||||||
|
.. ', -'
|
||||||
.. rk
|
.. rk
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -1379,8 +1591,8 @@ function M.auth(args)
|
||||||
local backends_list = discover_backends()
|
local backends_list = discover_backends()
|
||||||
local auth_backends = {}
|
local auth_backends = {}
|
||||||
for _, name in ipairs(backends_list) do
|
for _, name in ipairs(backends_list) do
|
||||||
local ok, mod = pcall(require, 'pending.sync.' .. name)
|
local mod = M.resolve_backend(name)
|
||||||
if ok and type(mod.auth) == 'function' then
|
if mod and type(mod.auth) == 'function' then
|
||||||
table.insert(auth_backends, { name = name, mod = mod })
|
table.insert(auth_backends, { name = name, mod = mod })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
local config = require('pending.config')
|
local config = require('pending.config')
|
||||||
|
local forge = require('pending.forge')
|
||||||
|
local log = require('pending.log')
|
||||||
|
|
||||||
---@class pending.Metadata
|
---@class pending.Metadata
|
||||||
---@field due? string
|
---@field due? string
|
||||||
---@field cat? string
|
---@field category? string
|
||||||
---@field rec? string
|
---@field recur? string
|
||||||
---@field rec_mode? 'scheduled'|'completion'
|
---@field recur_mode? pending.RecurMode
|
||||||
---@field priority? integer
|
---@field priority? integer
|
||||||
|
|
||||||
---@class pending.parse
|
---@class pending.parse
|
||||||
|
|
@ -107,6 +109,11 @@ local function is_valid_datetime(s)
|
||||||
return is_valid_date(date_part) and is_valid_time(time_part)
|
return is_valid_date(date_part) and is_valid_time(time_part)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return string
|
||||||
|
local function category_key()
|
||||||
|
return config.get().category_syntax or 'cat'
|
||||||
|
end
|
||||||
|
|
||||||
---@return string
|
---@return string
|
||||||
local function date_key()
|
local function date_key()
|
||||||
return config.get().date_syntax or 'due'
|
return config.get().date_syntax or 'due'
|
||||||
|
|
@ -530,83 +537,99 @@ function M.body(text)
|
||||||
end
|
end
|
||||||
|
|
||||||
local metadata = {}
|
local metadata = {}
|
||||||
local i = #tokens
|
local ck = category_key()
|
||||||
local dk = date_key()
|
local dk = date_key()
|
||||||
local rk = recur_key()
|
local rk = recur_key()
|
||||||
|
local cat_pattern = '^' .. vim.pesc(ck) .. ':(%S+)$'
|
||||||
local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d[T%d:]*)$'
|
local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d[T%d:]*)$'
|
||||||
local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$'
|
local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$'
|
||||||
local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$'
|
local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$'
|
||||||
|
local desc_tokens = {}
|
||||||
|
local forge_tokens = {}
|
||||||
|
|
||||||
|
for _, token in ipairs(tokens) do
|
||||||
|
local consumed = false
|
||||||
|
|
||||||
while i >= 1 do
|
|
||||||
local token = tokens[i]
|
|
||||||
local due_val = token:match(date_pattern_strict)
|
local due_val = token:match(date_pattern_strict)
|
||||||
if due_val then
|
if due_val and is_valid_datetime(due_val) then
|
||||||
if metadata.due then
|
if not metadata.due then
|
||||||
break
|
|
||||||
end
|
|
||||||
if not is_valid_datetime(due_val) then
|
|
||||||
break
|
|
||||||
end
|
|
||||||
metadata.due = due_val
|
metadata.due = due_val
|
||||||
i = i - 1
|
|
||||||
else
|
else
|
||||||
|
log.warn('duplicate ' .. dk .. ': token ignored: ' .. token)
|
||||||
|
end
|
||||||
|
consumed = true
|
||||||
|
end
|
||||||
|
if not consumed then
|
||||||
local raw_val = token:match(date_pattern_any)
|
local raw_val = token:match(date_pattern_any)
|
||||||
if raw_val then
|
if raw_val then
|
||||||
if metadata.due then
|
|
||||||
break
|
|
||||||
end
|
|
||||||
local resolved = M.resolve_date(raw_val)
|
local resolved = M.resolve_date(raw_val)
|
||||||
if not resolved then
|
if resolved then
|
||||||
break
|
if not metadata.due then
|
||||||
end
|
|
||||||
metadata.due = resolved
|
metadata.due = resolved
|
||||||
i = i - 1
|
|
||||||
else
|
else
|
||||||
local cat_val = token:match('^cat:(%S+)$')
|
log.warn('duplicate ' .. dk .. ': token ignored: ' .. token)
|
||||||
if cat_val then
|
|
||||||
if metadata.cat then
|
|
||||||
break
|
|
||||||
end
|
end
|
||||||
metadata.cat = cat_val
|
consumed = true
|
||||||
i = i - 1
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not consumed then
|
||||||
|
local cat_val = token:match(cat_pattern)
|
||||||
|
if cat_val then
|
||||||
|
if not metadata.category then
|
||||||
|
metadata.category = cat_val
|
||||||
else
|
else
|
||||||
|
log.warn('duplicate ' .. ck .. ': token ignored: ' .. token)
|
||||||
|
end
|
||||||
|
consumed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not consumed then
|
||||||
local pri_bangs = token:match('^%+(!+)$')
|
local pri_bangs = token:match('^%+(!+)$')
|
||||||
if pri_bangs then
|
if pri_bangs then
|
||||||
if metadata.priority then
|
if not metadata.priority then
|
||||||
break
|
|
||||||
end
|
|
||||||
local max = config.get().max_priority or 3
|
local max = config.get().max_priority or 3
|
||||||
metadata.priority = math.min(#pri_bangs, max)
|
metadata.priority = math.min(#pri_bangs, max)
|
||||||
i = i - 1
|
|
||||||
else
|
else
|
||||||
|
log.warn('duplicate priority token ignored: ' .. token)
|
||||||
|
end
|
||||||
|
consumed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not consumed then
|
||||||
local rec_val = token:match(rec_pattern)
|
local rec_val = token:match(rec_pattern)
|
||||||
if rec_val then
|
if rec_val then
|
||||||
if metadata.rec then
|
|
||||||
break
|
|
||||||
end
|
|
||||||
local recur = require('pending.recur')
|
local recur = require('pending.recur')
|
||||||
local raw_spec = rec_val
|
local raw_spec = rec_val
|
||||||
if raw_spec:sub(1, 1) == '!' then
|
if raw_spec:sub(1, 1) == '!' then
|
||||||
metadata.rec_mode = 'completion'
|
|
||||||
raw_spec = raw_spec:sub(2)
|
raw_spec = raw_spec:sub(2)
|
||||||
end
|
end
|
||||||
if not recur.validate(raw_spec) then
|
if recur.validate(raw_spec) then
|
||||||
break
|
if not metadata.recur then
|
||||||
end
|
metadata.recur_mode = rec_val:sub(1, 1) == '!' and 'completion' or nil
|
||||||
metadata.rec = raw_spec
|
metadata.recur = raw_spec
|
||||||
i = i - 1
|
|
||||||
else
|
else
|
||||||
break
|
log.warn('duplicate ' .. rk .. ': token ignored: ' .. token)
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
consumed = true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local desc_tokens = {}
|
if not consumed then
|
||||||
for j = 1, i do
|
if forge.parse_ref(token) then
|
||||||
table.insert(desc_tokens, tokens[j])
|
table.insert(forge_tokens, token)
|
||||||
|
else
|
||||||
|
table.insert(desc_tokens, token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, ft in ipairs(forge_tokens) do
|
||||||
|
table.insert(desc_tokens, ft)
|
||||||
end
|
end
|
||||||
local description = table.concat(desc_tokens, ' ')
|
local description = table.concat(desc_tokens, ' ')
|
||||||
|
|
||||||
|
|
@ -624,7 +647,7 @@ function M.command_add(text)
|
||||||
local rest = text:sub(#cat_prefix + 2):match('^%s*(.+)$')
|
local rest = text:sub(#cat_prefix + 2):match('^%s*(.+)$')
|
||||||
if rest then
|
if rest then
|
||||||
local desc, meta = M.body(rest)
|
local desc, meta = M.body(rest)
|
||||||
meta.cat = meta.cat or cat_prefix
|
meta.category = meta.category or cat_prefix
|
||||||
return desc, meta
|
return desc, meta
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
---@field freq 'daily'|'weekly'|'monthly'|'yearly'
|
---@field freq 'daily'|'weekly'|'monthly'|'yearly'
|
||||||
---@field interval integer
|
---@field interval integer
|
||||||
---@field byday? string[]
|
---@field byday? string[]
|
||||||
---@field from_completion boolean
|
---@field mode pending.RecurMode
|
||||||
---@field _raw? string
|
---@field _raw? string
|
||||||
|
|
||||||
---@class pending.recur
|
---@class pending.recur
|
||||||
|
|
@ -10,29 +10,29 @@ local M = {}
|
||||||
|
|
||||||
---@type table<string, pending.RecurSpec>
|
---@type table<string, pending.RecurSpec>
|
||||||
local named = {
|
local named = {
|
||||||
daily = { freq = 'daily', interval = 1, from_completion = false },
|
daily = { freq = 'daily', interval = 1, mode = 'scheduled' },
|
||||||
weekdays = {
|
weekdays = {
|
||||||
freq = 'weekly',
|
freq = 'weekly',
|
||||||
interval = 1,
|
interval = 1,
|
||||||
byday = { 'MO', 'TU', 'WE', 'TH', 'FR' },
|
byday = { 'MO', 'TU', 'WE', 'TH', 'FR' },
|
||||||
from_completion = false,
|
mode = 'scheduled',
|
||||||
},
|
},
|
||||||
weekly = { freq = 'weekly', interval = 1, from_completion = false },
|
weekly = { freq = 'weekly', interval = 1, mode = 'scheduled' },
|
||||||
biweekly = { freq = 'weekly', interval = 2, from_completion = false },
|
biweekly = { freq = 'weekly', interval = 2, mode = 'scheduled' },
|
||||||
monthly = { freq = 'monthly', interval = 1, from_completion = false },
|
monthly = { freq = 'monthly', interval = 1, mode = 'scheduled' },
|
||||||
quarterly = { freq = 'monthly', interval = 3, from_completion = false },
|
quarterly = { freq = 'monthly', interval = 3, mode = 'scheduled' },
|
||||||
yearly = { freq = 'yearly', interval = 1, from_completion = false },
|
yearly = { freq = 'yearly', interval = 1, mode = 'scheduled' },
|
||||||
annual = { freq = 'yearly', interval = 1, from_completion = false },
|
annual = { freq = 'yearly', interval = 1, mode = 'scheduled' },
|
||||||
}
|
}
|
||||||
|
|
||||||
---@param spec string
|
---@param spec string
|
||||||
---@return pending.RecurSpec?
|
---@return pending.RecurSpec?
|
||||||
function M.parse(spec)
|
function M.parse(spec)
|
||||||
local from_completion = false
|
local mode = 'scheduled' ---@type pending.RecurMode
|
||||||
local s = spec
|
local s = spec
|
||||||
|
|
||||||
if s:sub(1, 1) == '!' then
|
if s:sub(1, 1) == '!' then
|
||||||
from_completion = true
|
mode = 'completion'
|
||||||
s = s:sub(2)
|
s = s:sub(2)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -44,7 +44,7 @@ function M.parse(spec)
|
||||||
freq = base.freq,
|
freq = base.freq,
|
||||||
interval = base.interval,
|
interval = base.interval,
|
||||||
byday = base.byday,
|
byday = base.byday,
|
||||||
from_completion = from_completion,
|
mode = mode,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -58,7 +58,7 @@ function M.parse(spec)
|
||||||
return {
|
return {
|
||||||
freq = freq_map[unit],
|
freq = freq_map[unit],
|
||||||
interval = num,
|
interval = num,
|
||||||
from_completion = from_completion,
|
mode = mode,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -66,7 +66,7 @@ function M.parse(spec)
|
||||||
return {
|
return {
|
||||||
freq = 'daily',
|
freq = 'daily',
|
||||||
interval = 1,
|
interval = 1,
|
||||||
from_completion = from_completion,
|
mode = mode,
|
||||||
_raw = s,
|
_raw = s,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
@ -134,7 +134,7 @@ end
|
||||||
|
|
||||||
---@param base_date string
|
---@param base_date string
|
||||||
---@param spec string
|
---@param spec string
|
||||||
---@param mode 'scheduled'|'completion'
|
---@param mode pending.RecurMode
|
||||||
---@return string
|
---@return string
|
||||||
function M.next_due(base_date, spec, mode)
|
function M.next_due(base_date, spec, mode)
|
||||||
local parsed = M.parse(spec)
|
local parsed = M.parse(spec)
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,32 @@
|
||||||
local config = require('pending.config')
|
local config = require('pending.config')
|
||||||
|
|
||||||
|
---@alias pending.TaskStatus 'pending'|'done'|'deleted'|'wip'|'blocked'|'cancelled'
|
||||||
|
---@alias pending.RecurMode 'scheduled'|'completion'
|
||||||
|
|
||||||
|
---@class pending.TaskExtra
|
||||||
|
---@field _forge_ref? pending.ForgeRef
|
||||||
|
---@field _forge_cache? pending.ForgeCache
|
||||||
|
---@field _gtasks_task_id? string
|
||||||
|
---@field _gtasks_list_id? string
|
||||||
|
---@field _gcal_event_id? string
|
||||||
|
---@field _gcal_calendar_id? string
|
||||||
|
---@field [string] any
|
||||||
|
|
||||||
---@class pending.Task
|
---@class pending.Task
|
||||||
---@field id integer
|
---@field id integer
|
||||||
---@field description string
|
---@field description string
|
||||||
---@field status 'pending'|'done'|'deleted'|'wip'|'blocked'
|
---@field status pending.TaskStatus
|
||||||
---@field category? string
|
---@field category? string
|
||||||
---@field priority integer
|
---@field priority integer
|
||||||
---@field due? string
|
---@field due? string
|
||||||
---@field recur? string
|
---@field recur? string
|
||||||
---@field recur_mode? 'scheduled'|'completion'
|
---@field recur_mode? pending.RecurMode
|
||||||
---@field entry string
|
---@field entry string
|
||||||
---@field modified string
|
---@field modified string
|
||||||
---@field end? string
|
---@field end? string
|
||||||
|
---@field notes? string
|
||||||
---@field order integer
|
---@field order integer
|
||||||
---@field _extra? table<string, any>
|
---@field _extra? pending.TaskExtra
|
||||||
|
|
||||||
---@class pending.Data
|
---@class pending.Data
|
||||||
---@field version integer
|
---@field version integer
|
||||||
|
|
@ -24,14 +37,14 @@ local config = require('pending.config')
|
||||||
|
|
||||||
---@class pending.TaskFields
|
---@class pending.TaskFields
|
||||||
---@field description string
|
---@field description string
|
||||||
---@field status? string
|
---@field status? pending.TaskStatus
|
||||||
---@field category? string
|
---@field category? string
|
||||||
---@field priority? integer
|
---@field priority? integer
|
||||||
---@field due? string
|
---@field due? string
|
||||||
---@field recur? string
|
---@field recur? string
|
||||||
---@field recur_mode? string
|
---@field recur_mode? pending.RecurMode
|
||||||
---@field order? integer
|
---@field order? integer
|
||||||
---@field _extra? table
|
---@field _extra? pending.TaskExtra
|
||||||
|
|
||||||
---@class pending.Store
|
---@class pending.Store
|
||||||
---@field path string
|
---@field path string
|
||||||
|
|
@ -81,6 +94,7 @@ local known_fields = {
|
||||||
entry = true,
|
entry = true,
|
||||||
modified = true,
|
modified = true,
|
||||||
['end'] = true,
|
['end'] = true,
|
||||||
|
notes = true,
|
||||||
order = true,
|
order = true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,6 +126,9 @@ local function task_to_table(task)
|
||||||
if task['end'] then
|
if task['end'] then
|
||||||
t['end'] = task['end']
|
t['end'] = task['end']
|
||||||
end
|
end
|
||||||
|
if task.notes then
|
||||||
|
t.notes = task.notes
|
||||||
|
end
|
||||||
if task.order and task.order ~= 0 then
|
if task.order and task.order ~= 0 then
|
||||||
t.order = task.order
|
t.order = task.order
|
||||||
end
|
end
|
||||||
|
|
@ -138,6 +155,7 @@ local function table_to_task(t)
|
||||||
entry = t.entry,
|
entry = t.entry,
|
||||||
modified = t.modified,
|
modified = t.modified,
|
||||||
['end'] = t['end'],
|
['end'] = t['end'],
|
||||||
|
notes = t.notes,
|
||||||
order = t.order or 0,
|
order = t.order or 0,
|
||||||
_extra = {},
|
_extra = {},
|
||||||
}
|
}
|
||||||
|
|
@ -319,7 +337,7 @@ function Store:update(id, fields)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
task.modified = now
|
task.modified = now
|
||||||
if fields.status == 'done' or fields.status == 'deleted' then
|
if fields.status == 'done' or fields.status == 'deleted' or fields.status == 'cancelled' then
|
||||||
task['end'] = task['end'] or now
|
task['end'] = task['end'] or now
|
||||||
end
|
end
|
||||||
return task
|
return task
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,7 @@ function M.push()
|
||||||
and (
|
and (
|
||||||
task.status == 'done'
|
task.status == 'done'
|
||||||
or task.status == 'deleted'
|
or task.status == 'deleted'
|
||||||
|
or task.status == 'cancelled'
|
||||||
or (task.status == 'pending' and not task.due)
|
or (task.status == 'pending' and not task.due)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,15 @@ local BUNDLED_CLIENT_SECRET = 'PLACEHOLDER'
|
||||||
---@field config_key string
|
---@field config_key string
|
||||||
|
|
||||||
---@class pending.OAuthClient : pending.OAuthClientOpts
|
---@class pending.OAuthClient : pending.OAuthClientOpts
|
||||||
|
---@field token_path fun(self: pending.OAuthClient): string
|
||||||
|
---@field resolve_credentials fun(self: pending.OAuthClient): pending.OAuthCredentials
|
||||||
|
---@field load_tokens fun(self: pending.OAuthClient): pending.OAuthTokens?
|
||||||
|
---@field save_tokens fun(self: pending.OAuthClient, tokens: pending.OAuthTokens): boolean
|
||||||
|
---@field refresh_access_token fun(self: pending.OAuthClient, creds: pending.OAuthCredentials, tokens: pending.OAuthTokens): pending.OAuthTokens?
|
||||||
|
---@field get_access_token fun(self: pending.OAuthClient): string?
|
||||||
|
---@field setup fun(self: pending.OAuthClient): nil
|
||||||
|
---@field auth fun(self: pending.OAuthClient, on_complete?: fun(ok: boolean): nil): nil
|
||||||
|
---@field clear_tokens fun(self: pending.OAuthClient): nil
|
||||||
local OAuthClient = {}
|
local OAuthClient = {}
|
||||||
OAuthClient.__index = OAuthClient
|
OAuthClient.__index = OAuthClient
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ local parse = require('pending.parse')
|
||||||
---@field id? integer
|
---@field id? integer
|
||||||
---@field due? string
|
---@field due? string
|
||||||
---@field raw_due? string
|
---@field raw_due? string
|
||||||
---@field status? string
|
---@field status? pending.TaskStatus
|
||||||
---@field category? string
|
---@field category? string
|
||||||
---@field overdue? boolean
|
---@field overdue? boolean
|
||||||
---@field show_category? boolean
|
---@field show_category? boolean
|
||||||
|
|
@ -71,21 +71,24 @@ local function compute_forge_spans(task, prefix_len)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@type table<string, integer>
|
---@type table<string, integer>
|
||||||
local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3 }
|
local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3, cancelled = 4 }
|
||||||
|
|
||||||
---@param task pending.Task
|
---@param task pending.Task
|
||||||
---@return string
|
---@return string
|
||||||
local function state_char(task)
|
local function state_char(task)
|
||||||
|
local icons = config.get().icons
|
||||||
if task.status == 'done' then
|
if task.status == 'done' then
|
||||||
return 'x'
|
return icons.done
|
||||||
|
elseif task.status == 'cancelled' then
|
||||||
|
return icons.cancelled
|
||||||
elseif task.status == 'wip' then
|
elseif task.status == 'wip' then
|
||||||
return '>'
|
return icons.wip
|
||||||
elseif task.status == 'blocked' then
|
elseif task.status == 'blocked' then
|
||||||
return '='
|
return icons.blocked
|
||||||
elseif task.priority > 0 then
|
elseif task.priority > 0 then
|
||||||
return '!'
|
return icons.priority
|
||||||
end
|
end
|
||||||
return ' '
|
return icons.pending
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param tasks pending.Task[]
|
---@param tasks pending.Task[]
|
||||||
|
|
@ -106,17 +109,23 @@ local function sort_tasks(tasks)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param tasks pending.Task[]
|
---@type table<string, fun(a: pending.Task, b: pending.Task): boolean?>
|
||||||
local function sort_tasks_priority(tasks)
|
local sort_key_comparators = {
|
||||||
table.sort(tasks, function(a, b)
|
status = function(a, b)
|
||||||
local ra = status_rank[a.status] or 1
|
local ra = status_rank[a.status] or 1
|
||||||
local rb = status_rank[b.status] or 1
|
local rb = status_rank[b.status] or 1
|
||||||
if ra ~= rb then
|
if ra ~= rb then
|
||||||
return ra < rb
|
return ra < rb
|
||||||
end
|
end
|
||||||
|
return nil
|
||||||
|
end,
|
||||||
|
priority = function(a, b)
|
||||||
if a.priority ~= b.priority then
|
if a.priority ~= b.priority then
|
||||||
return a.priority > b.priority
|
return a.priority > b.priority
|
||||||
end
|
end
|
||||||
|
return nil
|
||||||
|
end,
|
||||||
|
due = function(a, b)
|
||||||
local a_due = a.due or ''
|
local a_due = a.due or ''
|
||||||
local b_due = b.due or ''
|
local b_due = b.due or ''
|
||||||
if a_due ~= b_due then
|
if a_due ~= b_due then
|
||||||
|
|
@ -128,11 +137,61 @@ local function sort_tasks_priority(tasks)
|
||||||
end
|
end
|
||||||
return a_due < b_due
|
return a_due < b_due
|
||||||
end
|
end
|
||||||
|
return nil
|
||||||
|
end,
|
||||||
|
order = function(a, b)
|
||||||
if a.order ~= b.order then
|
if a.order ~= b.order then
|
||||||
return a.order < b.order
|
return a.order < b.order
|
||||||
end
|
end
|
||||||
|
return nil
|
||||||
|
end,
|
||||||
|
id = function(a, b)
|
||||||
|
if a.id ~= b.id then
|
||||||
return a.id < b.id
|
return a.id < b.id
|
||||||
end)
|
end
|
||||||
|
return nil
|
||||||
|
end,
|
||||||
|
age = function(a, b)
|
||||||
|
if a.id ~= b.id then
|
||||||
|
return a.id < b.id
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
---@return fun(a: pending.Task, b: pending.Task): boolean
|
||||||
|
local function build_queue_comparator()
|
||||||
|
local log = require('pending.log')
|
||||||
|
local keys = config.get().view.queue.sort or { 'status', 'priority', 'due', 'order', 'id' }
|
||||||
|
local comparators = {}
|
||||||
|
local unknown = {}
|
||||||
|
for _, key in ipairs(keys) do
|
||||||
|
local cmp = sort_key_comparators[key]
|
||||||
|
if cmp then
|
||||||
|
table.insert(comparators, cmp)
|
||||||
|
else
|
||||||
|
table.insert(unknown, key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #unknown > 0 then
|
||||||
|
local label = #unknown == 1 and 'unknown queue sort key: ' or 'unknown queue sort keys: '
|
||||||
|
log.warn(label .. table.concat(unknown, ', '))
|
||||||
|
end
|
||||||
|
return function(a, b)
|
||||||
|
for _, cmp in ipairs(comparators) do
|
||||||
|
local result = cmp(a, b)
|
||||||
|
if result ~= nil then
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param tasks pending.Task[]
|
||||||
|
local function sort_tasks_priority(tasks)
|
||||||
|
local cmp = build_queue_comparator()
|
||||||
|
table.sort(tasks, cmp)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param tasks pending.Task[]
|
---@param tasks pending.Task[]
|
||||||
|
|
@ -152,7 +211,7 @@ function M.category_view(tasks)
|
||||||
by_cat[cat] = {}
|
by_cat[cat] = {}
|
||||||
done_by_cat[cat] = {}
|
done_by_cat[cat] = {}
|
||||||
end
|
end
|
||||||
if task.status == 'done' or task.status == 'deleted' then
|
if task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled' then
|
||||||
table.insert(done_by_cat[cat], task)
|
table.insert(done_by_cat[cat], task)
|
||||||
else
|
else
|
||||||
table.insert(by_cat[cat], task)
|
table.insert(by_cat[cat], task)
|
||||||
|
|
@ -215,7 +274,11 @@ function M.category_view(tasks)
|
||||||
status = task.status,
|
status = task.status,
|
||||||
category = cat,
|
category = cat,
|
||||||
priority = task.priority,
|
priority = task.priority,
|
||||||
overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil,
|
overdue = task.status ~= 'done'
|
||||||
|
and task.status ~= 'cancelled'
|
||||||
|
and task.due ~= nil
|
||||||
|
and parse.is_overdue(task.due)
|
||||||
|
or nil,
|
||||||
recur = task.recur,
|
recur = task.recur,
|
||||||
forge_spans = compute_forge_spans(task, prefix_len),
|
forge_spans = compute_forge_spans(task, prefix_len),
|
||||||
})
|
})
|
||||||
|
|
@ -233,7 +296,7 @@ function M.priority_view(tasks)
|
||||||
local done = {}
|
local done = {}
|
||||||
|
|
||||||
for _, task in ipairs(tasks) do
|
for _, task in ipairs(tasks) do
|
||||||
if task.status == 'done' then
|
if task.status == 'done' or task.status == 'cancelled' then
|
||||||
table.insert(done, task)
|
table.insert(done, task)
|
||||||
else
|
else
|
||||||
table.insert(pending, task)
|
table.insert(pending, task)
|
||||||
|
|
@ -256,7 +319,7 @@ function M.priority_view(tasks)
|
||||||
|
|
||||||
for _, task in ipairs(all) do
|
for _, task in ipairs(all) do
|
||||||
local prefix = '/' .. task.id .. '/'
|
local prefix = '/' .. task.id .. '/'
|
||||||
local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ')
|
local state = state_char(task)
|
||||||
local line = prefix .. '- [' .. state .. '] ' .. task.description
|
local line = prefix .. '- [' .. state .. '] ' .. task.description
|
||||||
local prefix_len = #prefix + #('- [' .. state .. '] ')
|
local prefix_len = #prefix + #('- [' .. state .. '] ')
|
||||||
table.insert(lines, line)
|
table.insert(lines, line)
|
||||||
|
|
@ -268,12 +331,17 @@ function M.priority_view(tasks)
|
||||||
status = task.status,
|
status = task.status,
|
||||||
category = task.category,
|
category = task.category,
|
||||||
priority = task.priority,
|
priority = task.priority,
|
||||||
overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil,
|
overdue = task.status ~= 'done'
|
||||||
|
and task.status ~= 'cancelled'
|
||||||
|
and task.due ~= nil
|
||||||
|
and parse.is_overdue(task.due)
|
||||||
|
or nil,
|
||||||
show_category = true,
|
show_category = true,
|
||||||
recur = task.recur,
|
recur = task.recur,
|
||||||
forge_ref = task._extra and task._extra._forge_ref or nil,
|
forge_ref = task._extra and task._extra._forge_ref or nil,
|
||||||
forge_cache = task._extra and task._extra._forge_cache or nil,
|
forge_cache = task._extra and task._extra._forge_cache or nil,
|
||||||
forge_spans = compute_forge_spans(task, prefix_len),
|
forge_spans = compute_forge_spans(task, prefix_len),
|
||||||
|
has_notes = task.notes ~= nil and task.notes ~= '',
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,18 +6,19 @@ vim.g.loaded_pending = true
|
||||||
---@return string[]
|
---@return string[]
|
||||||
local function edit_field_candidates()
|
local function edit_field_candidates()
|
||||||
local cfg = require('pending.config').get()
|
local cfg = require('pending.config').get()
|
||||||
|
local ck = cfg.category_syntax or 'cat'
|
||||||
local dk = cfg.date_syntax or 'due'
|
local dk = cfg.date_syntax or 'due'
|
||||||
local rk = cfg.recur_syntax or 'rec'
|
local rk = cfg.recur_syntax or 'rec'
|
||||||
return {
|
return {
|
||||||
dk .. ':',
|
dk .. ':',
|
||||||
'cat:',
|
ck .. ':',
|
||||||
rk .. ':',
|
rk .. ':',
|
||||||
'+!',
|
'+!',
|
||||||
'+!!',
|
'+!!',
|
||||||
'+!!!',
|
'+!!!',
|
||||||
'-!',
|
'-!',
|
||||||
'-' .. dk,
|
'-' .. dk,
|
||||||
'-cat',
|
'-' .. ck,
|
||||||
'-' .. rk,
|
'-' .. rk,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
@ -79,6 +80,65 @@ local function filter_candidates(lead, candidates)
|
||||||
end, candidates)
|
end, candidates)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@param arg_lead string
|
||||||
|
---@return string[]
|
||||||
|
local function complete_add(arg_lead)
|
||||||
|
local cfg = require('pending.config').get()
|
||||||
|
local dk = cfg.date_syntax or 'due'
|
||||||
|
local rk = cfg.recur_syntax or 'rec'
|
||||||
|
local ck = cfg.category_syntax or 'cat'
|
||||||
|
|
||||||
|
local prefix = arg_lead:match('^(' .. vim.pesc(dk) .. ':)(.*)$')
|
||||||
|
if prefix then
|
||||||
|
local after_colon = arg_lead:sub(#prefix + 1)
|
||||||
|
local result = {}
|
||||||
|
for _, d in ipairs(edit_date_values()) 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 result = {}
|
||||||
|
for _, p in ipairs(edit_recur_values()) 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('^(' .. vim.pesc(ck) .. ':)(.*)$')
|
||||||
|
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 {}
|
||||||
|
end
|
||||||
|
|
||||||
---@param arg_lead string
|
---@param arg_lead string
|
||||||
---@param cmd_line string
|
---@param cmd_line string
|
||||||
---@return string[]
|
---@return string[]
|
||||||
|
|
@ -135,7 +195,9 @@ local function complete_edit(arg_lead, cmd_line)
|
||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
local cat_prefix = arg_lead:match('^(cat:)(.*)$')
|
local ck = cfg.category_syntax or 'cat'
|
||||||
|
|
||||||
|
local cat_prefix = arg_lead:match('^(' .. vim.pesc(ck) .. ':)(.*)$')
|
||||||
if cat_prefix then
|
if cat_prefix then
|
||||||
local after_colon = arg_lead:sub(#cat_prefix + 1)
|
local after_colon = arg_lead:sub(#cat_prefix + 1)
|
||||||
local store = require('pending.store')
|
local store = require('pending.store')
|
||||||
|
|
@ -183,8 +245,17 @@ end, {
|
||||||
for word in after_filter:gmatch('%S+') do
|
for word in after_filter:gmatch('%S+') do
|
||||||
used[word] = true
|
used[word] = true
|
||||||
end
|
end
|
||||||
local candidates =
|
local candidates = {
|
||||||
{ 'clear', 'overdue', 'today', 'priority', 'done', 'pending', 'wip', 'blocked' }
|
'clear',
|
||||||
|
'overdue',
|
||||||
|
'today',
|
||||||
|
'priority',
|
||||||
|
'done',
|
||||||
|
'pending',
|
||||||
|
'wip',
|
||||||
|
'blocked',
|
||||||
|
'cancelled',
|
||||||
|
}
|
||||||
local store = require('pending.store')
|
local store = require('pending.store')
|
||||||
local s = store.new(store.resolve_path())
|
local s = store.new(store.resolve_path())
|
||||||
s:load()
|
s:load()
|
||||||
|
|
@ -192,7 +263,8 @@ end, {
|
||||||
for _, task in ipairs(s:active_tasks()) do
|
for _, task in ipairs(s:active_tasks()) do
|
||||||
if task.category and not seen[task.category] then
|
if task.category and not seen[task.category] then
|
||||||
seen[task.category] = true
|
seen[task.category] = true
|
||||||
table.insert(candidates, 'cat:' .. task.category)
|
local ck = (require('pending.config').get().category_syntax or 'cat')
|
||||||
|
table.insert(candidates, ck .. ':' .. task.category)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local filtered = {}
|
local filtered = {}
|
||||||
|
|
@ -203,6 +275,9 @@ end, {
|
||||||
end
|
end
|
||||||
return filtered
|
return filtered
|
||||||
end
|
end
|
||||||
|
if cmd_line:match('^Pending%s+add%s') then
|
||||||
|
return complete_add(arg_lead)
|
||||||
|
end
|
||||||
if cmd_line:match('^Pending%s+archive%s') then
|
if cmd_line:match('^Pending%s+archive%s') then
|
||||||
return filter_candidates(arg_lead, { '7d', '2w', '30d', '3m', '6m', '1y' })
|
return filter_candidates(arg_lead, { '7d', '2w', '30d', '3m', '6m', '1y' })
|
||||||
end
|
end
|
||||||
|
|
@ -229,8 +304,8 @@ end, {
|
||||||
if #parts == 0 or (#parts == 1 and not trailing) then
|
if #parts == 0 or (#parts == 1 and not trailing) then
|
||||||
local auth_names = {}
|
local auth_names = {}
|
||||||
for _, b in ipairs(pending.sync_backends()) do
|
for _, b in ipairs(pending.sync_backends()) do
|
||||||
local ok, mod = pcall(require, 'pending.sync.' .. b)
|
local mod = pending.resolve_backend(b)
|
||||||
if ok and type(mod.auth) == 'function' then
|
if mod and type(mod.auth) == 'function' then
|
||||||
table.insert(auth_names, b)
|
table.insert(auth_names, b)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -238,8 +313,8 @@ end, {
|
||||||
end
|
end
|
||||||
local backend_name = parts[1]
|
local backend_name = parts[1]
|
||||||
if #parts == 1 or (#parts == 2 and not trailing) then
|
if #parts == 1 or (#parts == 2 and not trailing) then
|
||||||
local ok, mod = pcall(require, 'pending.sync.' .. backend_name)
|
local mod = pending.resolve_backend(backend_name)
|
||||||
if ok and type(mod.auth_complete) == 'function' then
|
if mod and type(mod.auth_complete) == 'function' then
|
||||||
return filter_candidates(arg_lead, mod.auth_complete())
|
return filter_candidates(arg_lead, mod.auth_complete())
|
||||||
end
|
end
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -253,8 +328,8 @@ end, {
|
||||||
if not after_backend then
|
if not after_backend then
|
||||||
return {}
|
return {}
|
||||||
end
|
end
|
||||||
local ok, mod = pcall(require, 'pending.sync.' .. matched_backend)
|
local mod = pending.resolve_backend(matched_backend)
|
||||||
if not ok then
|
if not mod then
|
||||||
return {}
|
return {}
|
||||||
end
|
end
|
||||||
local actions = {}
|
local actions = {}
|
||||||
|
|
@ -328,6 +403,14 @@ vim.keymap.set('n', '<Plug>(pending-blocked)', function()
|
||||||
require('pending').toggle_status('blocked')
|
require('pending').toggle_status('blocked')
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
vim.keymap.set('n', '<Plug>(pending-cancelled)', function()
|
||||||
|
require('pending').toggle_status('cancelled')
|
||||||
|
end)
|
||||||
|
|
||||||
|
vim.keymap.set('n', '<Plug>(pending-edit-notes)', function()
|
||||||
|
require('pending').open_detail()
|
||||||
|
end)
|
||||||
|
|
||||||
vim.keymap.set('n', '<Plug>(pending-priority-up)', function()
|
vim.keymap.set('n', '<Plug>(pending-priority-up)', function()
|
||||||
require('pending').increment_priority()
|
require('pending').increment_priority()
|
||||||
end)
|
end)
|
||||||
|
|
@ -336,6 +419,16 @@ vim.keymap.set('n', '<Plug>(pending-priority-down)', function()
|
||||||
require('pending').decrement_priority()
|
require('pending').decrement_priority()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
vim.keymap.set('x', '<Plug>(pending-priority-up-visual)', function()
|
||||||
|
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('<Esc>', true, false, true), 'nx', false)
|
||||||
|
require('pending').increment_priority_visual()
|
||||||
|
end)
|
||||||
|
|
||||||
|
vim.keymap.set('x', '<Plug>(pending-priority-down-visual)', function()
|
||||||
|
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('<Esc>', true, false, true), 'nx', false)
|
||||||
|
require('pending').decrement_priority_visual()
|
||||||
|
end)
|
||||||
|
|
||||||
vim.keymap.set('n', '<Plug>(pending-filter)', function()
|
vim.keymap.set('n', '<Plug>(pending-filter)', function()
|
||||||
vim.ui.input({ prompt = 'Filter: ' }, function(input)
|
vim.ui.input({ prompt = 'Filter: ' }, function(input)
|
||||||
if input then
|
if input then
|
||||||
|
|
|
||||||
402
spec/detail_spec.lua
Normal file
402
spec/detail_spec.lua
Normal file
|
|
@ -0,0 +1,402 @@
|
||||||
|
require('spec.helpers')
|
||||||
|
|
||||||
|
local config = require('pending.config')
|
||||||
|
|
||||||
|
describe('detail frontmatter', function()
|
||||||
|
local buffer
|
||||||
|
local tmpdir
|
||||||
|
|
||||||
|
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
|
||||||
|
buffer = require('pending.buffer')
|
||||||
|
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('build_detail_frontmatter', function()
|
||||||
|
it('renders status and priority for minimal task', function()
|
||||||
|
local lines = buffer._build_detail_frontmatter({
|
||||||
|
id = 1,
|
||||||
|
description = 'Test',
|
||||||
|
status = 'pending',
|
||||||
|
priority = 0,
|
||||||
|
entry = '',
|
||||||
|
modified = '',
|
||||||
|
order = 0,
|
||||||
|
})
|
||||||
|
assert.are.equal(2, #lines)
|
||||||
|
assert.are.equal('Status: pending', lines[1])
|
||||||
|
assert.are.equal('Priority: 0', lines[2])
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('renders all fields', function()
|
||||||
|
local lines = buffer._build_detail_frontmatter({
|
||||||
|
id = 1,
|
||||||
|
description = 'Test',
|
||||||
|
status = 'wip',
|
||||||
|
priority = 2,
|
||||||
|
category = 'Work',
|
||||||
|
due = '2026-03-15',
|
||||||
|
recur = 'weekly',
|
||||||
|
entry = '',
|
||||||
|
modified = '',
|
||||||
|
order = 0,
|
||||||
|
})
|
||||||
|
assert.are.equal(5, #lines)
|
||||||
|
assert.are.equal('Status: wip', lines[1])
|
||||||
|
assert.are.equal('Priority: 2', lines[2])
|
||||||
|
assert.are.equal('Category: Work', lines[3])
|
||||||
|
assert.are.equal('Due: 2026-03-15', lines[4])
|
||||||
|
assert.are.equal('Recur: weekly', lines[5])
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('prefixes recur with ! for completion mode', function()
|
||||||
|
local lines = buffer._build_detail_frontmatter({
|
||||||
|
id = 1,
|
||||||
|
description = 'Test',
|
||||||
|
status = 'pending',
|
||||||
|
priority = 0,
|
||||||
|
recur = 'daily',
|
||||||
|
recur_mode = 'completion',
|
||||||
|
entry = '',
|
||||||
|
modified = '',
|
||||||
|
order = 0,
|
||||||
|
})
|
||||||
|
assert.are.equal('Recur: !daily', lines[3])
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('omits optional fields when absent', function()
|
||||||
|
local lines = buffer._build_detail_frontmatter({
|
||||||
|
id = 1,
|
||||||
|
description = 'Test',
|
||||||
|
status = 'done',
|
||||||
|
priority = 1,
|
||||||
|
entry = '',
|
||||||
|
modified = '',
|
||||||
|
order = 0,
|
||||||
|
})
|
||||||
|
assert.are.equal(2, #lines)
|
||||||
|
assert.are.equal('Status: done', lines[1])
|
||||||
|
assert.are.equal('Priority: 1', lines[2])
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('parse_detail_frontmatter', function()
|
||||||
|
it('parses minimal frontmatter', function()
|
||||||
|
local lines = {
|
||||||
|
'# My task',
|
||||||
|
'Status: pending',
|
||||||
|
'Priority: 0',
|
||||||
|
'---',
|
||||||
|
'some notes',
|
||||||
|
}
|
||||||
|
local sep, fields, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.is_nil(err)
|
||||||
|
assert.are.equal(4, sep)
|
||||||
|
assert.are.equal('My task', fields.description)
|
||||||
|
assert.are.equal('pending', fields.status)
|
||||||
|
assert.are.equal(0, fields.priority)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses all fields', function()
|
||||||
|
local lines = {
|
||||||
|
'# Fix the bug',
|
||||||
|
'Status: wip',
|
||||||
|
'Priority: 2',
|
||||||
|
'Category: Work',
|
||||||
|
'Due: 2026-03-15',
|
||||||
|
'Recur: weekly',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local sep, fields, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.is_nil(err)
|
||||||
|
assert.are.equal(7, sep)
|
||||||
|
assert.are.equal('Fix the bug', fields.description)
|
||||||
|
assert.are.equal('wip', fields.status)
|
||||||
|
assert.are.equal(2, fields.priority)
|
||||||
|
assert.are.equal('Work', fields.category)
|
||||||
|
assert.are.equal('2026-03-15', fields.due)
|
||||||
|
assert.are.equal('weekly', fields.recur)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves due date keywords', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'Status: pending',
|
||||||
|
'Priority: 0',
|
||||||
|
'Due: tomorrow',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, fields, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.is_nil(err)
|
||||||
|
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, fields.due)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses completion-mode recurrence', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'Status: pending',
|
||||||
|
'Priority: 0',
|
||||||
|
'Recur: !daily',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, fields, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.is_nil(err)
|
||||||
|
assert.are.equal('daily', fields.recur)
|
||||||
|
assert.are.equal('completion', fields.recur_mode)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('clears optional fields when lines removed', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'Status: done',
|
||||||
|
'Priority: 1',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, fields, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.is_nil(err)
|
||||||
|
assert.are.equal(vim.NIL, fields.category)
|
||||||
|
assert.are.equal(vim.NIL, fields.due)
|
||||||
|
assert.are.equal(vim.NIL, fields.recur)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('skips blank lines in frontmatter', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'Status: pending',
|
||||||
|
'',
|
||||||
|
'Priority: 0',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, fields, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.is_nil(err)
|
||||||
|
assert.are.equal('pending', fields.status)
|
||||||
|
assert.are.equal(0, fields.priority)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on missing separator', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'Status: pending',
|
||||||
|
'Priority: 0',
|
||||||
|
}
|
||||||
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.truthy(err:find('missing separator'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on missing title', function()
|
||||||
|
local lines = {
|
||||||
|
'',
|
||||||
|
'Status: pending',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.truthy(err:find('missing or empty title'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on empty title', function()
|
||||||
|
local lines = {
|
||||||
|
'# ',
|
||||||
|
'Status: pending',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.truthy(err:find('missing or empty title'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on invalid status', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'Status: bogus',
|
||||||
|
'Priority: 0',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.truthy(err:find('invalid status'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on negative priority', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'Status: pending',
|
||||||
|
'Priority: -1',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.truthy(err:find('invalid priority'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on non-integer priority', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'Status: pending',
|
||||||
|
'Priority: 1.5',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.truthy(err:find('invalid priority'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on priority exceeding max', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'Status: pending',
|
||||||
|
'Priority: 4',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.truthy(err:find('max is 3'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on invalid due date', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'Status: pending',
|
||||||
|
'Priority: 0',
|
||||||
|
'Due: notadate',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.truthy(err:find('invalid due date'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on empty due value', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'Due: ',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.truthy(err:find('empty due value'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on invalid recurrence', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'Recur: nope',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.truthy(err:find('invalid recurrence'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on empty recur value', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'Recur: ',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.truthy(err:find('empty recur value'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on empty category value', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'Category: ',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.truthy(err:find('empty category'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on unknown field', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'Status: pending',
|
||||||
|
'Foo: bar',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.truthy(err:find('unknown field: foo'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on duplicate field', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'Status: pending',
|
||||||
|
'Status: done',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.truthy(err:find('duplicate field'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on malformed frontmatter line', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'not a key value pair',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.truthy(err:find('invalid frontmatter line'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('is case-insensitive for field keys', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'STATUS: wip',
|
||||||
|
'PRIORITY: 1',
|
||||||
|
'CATEGORY: Work',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, fields, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.is_nil(err)
|
||||||
|
assert.are.equal('wip', fields.status)
|
||||||
|
assert.are.equal(1, fields.priority)
|
||||||
|
assert.are.equal('Work', fields.category)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('accepts datetime due format', function()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'Due: 2026-03-15T14:00',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, fields, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.is_nil(err)
|
||||||
|
assert.are.equal('2026-03-15T14:00', fields.due)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('respects custom max_priority', function()
|
||||||
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json', max_priority = 5 }
|
||||||
|
config.reset()
|
||||||
|
local lines = {
|
||||||
|
'# Task',
|
||||||
|
'Priority: 5',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, fields, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.is_nil(err)
|
||||||
|
assert.are.equal(5, fields.priority)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('updates description from title line', function()
|
||||||
|
local lines = {
|
||||||
|
'# Updated title',
|
||||||
|
'Status: pending',
|
||||||
|
'Priority: 0',
|
||||||
|
'---',
|
||||||
|
}
|
||||||
|
local _, fields, err = buffer._parse_detail_frontmatter(lines)
|
||||||
|
assert.is_nil(err)
|
||||||
|
assert.are.equal('Updated title', fields.description)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
@ -71,7 +71,7 @@ describe('diff', function()
|
||||||
'/1/- [ ] Take trash out rec:weekly',
|
'/1/- [ ] Take trash out rec:weekly',
|
||||||
}
|
}
|
||||||
local result = diff.parse_buffer(lines)
|
local result = diff.parse_buffer(lines)
|
||||||
assert.are.equal('weekly', result[2].rec)
|
assert.are.equal('weekly', result[2].recur)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('extracts rec: with completion mode', function()
|
it('extracts rec: with completion mode', function()
|
||||||
|
|
@ -80,8 +80,8 @@ describe('diff', function()
|
||||||
'/1/- [ ] Water plants rec:!daily',
|
'/1/- [ ] Water plants rec:!daily',
|
||||||
}
|
}
|
||||||
local result = diff.parse_buffer(lines)
|
local result = diff.parse_buffer(lines)
|
||||||
assert.are.equal('daily', result[2].rec)
|
assert.are.equal('daily', result[2].recur)
|
||||||
assert.are.equal('completion', result[2].rec_mode)
|
assert.are.equal('completion', result[2].recur_mode)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('inline due: token is parsed', function()
|
it('inline due: token is parsed', function()
|
||||||
|
|
@ -275,6 +275,98 @@ describe('diff', function()
|
||||||
assert.are.equal('completion', tasks[1].recur_mode)
|
assert.are.equal('completion', tasks[1].recur_mode)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('returns forge refs for new tasks', function()
|
||||||
|
local lines = {
|
||||||
|
'# Inbox',
|
||||||
|
'- [ ] Fix bug gh:user/repo#42',
|
||||||
|
}
|
||||||
|
local refs = diff.apply(lines, s)
|
||||||
|
assert.are.equal(1, #refs)
|
||||||
|
assert.are.equal('github', refs[1].forge)
|
||||||
|
assert.are.equal(42, refs[1].number)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns forge refs for changed refs on existing tasks', function()
|
||||||
|
s:add({
|
||||||
|
description = 'Fix bug gh:user/repo#1',
|
||||||
|
_extra = {
|
||||||
|
_forge_ref = {
|
||||||
|
forge = 'github',
|
||||||
|
owner = 'user',
|
||||||
|
repo = 'repo',
|
||||||
|
type = 'issue',
|
||||||
|
number = 1,
|
||||||
|
url = '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
s:save()
|
||||||
|
local lines = {
|
||||||
|
'# Todo',
|
||||||
|
'/1/- [ ] Fix bug gh:user/repo#99',
|
||||||
|
}
|
||||||
|
local refs = diff.apply(lines, s)
|
||||||
|
assert.are.equal(1, #refs)
|
||||||
|
assert.are.equal(99, refs[1].number)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns empty when forge ref is unchanged', function()
|
||||||
|
s:add({
|
||||||
|
description = 'Fix bug gh:user/repo#42',
|
||||||
|
_extra = {
|
||||||
|
_forge_ref = {
|
||||||
|
forge = 'github',
|
||||||
|
owner = 'user',
|
||||||
|
repo = 'repo',
|
||||||
|
type = 'issue',
|
||||||
|
number = 42,
|
||||||
|
url = '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
s:save()
|
||||||
|
local lines = {
|
||||||
|
'# Todo',
|
||||||
|
'/1/- [ ] Fix bug gh:user/repo#42',
|
||||||
|
}
|
||||||
|
local refs = diff.apply(lines, s)
|
||||||
|
assert.are.equal(0, #refs)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns empty for tasks without forge refs', function()
|
||||||
|
local lines = {
|
||||||
|
'# Inbox',
|
||||||
|
'- [ ] Plain task',
|
||||||
|
}
|
||||||
|
local refs = diff.apply(lines, s)
|
||||||
|
assert.are.equal(0, #refs)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns forge refs for duplicated tasks', function()
|
||||||
|
s:add({
|
||||||
|
description = 'Fix bug gh:user/repo#42',
|
||||||
|
_extra = {
|
||||||
|
_forge_ref = {
|
||||||
|
forge = 'github',
|
||||||
|
owner = 'user',
|
||||||
|
repo = 'repo',
|
||||||
|
type = 'issue',
|
||||||
|
number = 42,
|
||||||
|
url = '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
s:save()
|
||||||
|
local lines = {
|
||||||
|
'# Todo',
|
||||||
|
'/1/- [ ] Fix bug gh:user/repo#42',
|
||||||
|
'/1/- [ ] Fix bug gh:user/repo#42',
|
||||||
|
}
|
||||||
|
local refs = diff.apply(lines, s)
|
||||||
|
assert.are.equal(1, #refs)
|
||||||
|
assert.are.equal(42, refs[1].number)
|
||||||
|
end)
|
||||||
|
|
||||||
it('clears priority when [N] is removed from buffer line', function()
|
it('clears priority when [N] is removed from buffer line', function()
|
||||||
s:add({ description = 'Task name', priority = 1 })
|
s:add({ description = 'Task name', priority = 1 })
|
||||||
s:save()
|
s:save()
|
||||||
|
|
@ -287,5 +379,41 @@ describe('diff', function()
|
||||||
local task = s:get(1)
|
local task = s:get(1)
|
||||||
assert.are.equal(0, task.priority)
|
assert.are.equal(0, task.priority)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('sets priority from +!! token', function()
|
||||||
|
local lines = {
|
||||||
|
'# Inbox',
|
||||||
|
'- [ ] Pay bills +!!',
|
||||||
|
}
|
||||||
|
diff.apply(lines, s)
|
||||||
|
s:load()
|
||||||
|
local task = s:get(1)
|
||||||
|
assert.are.equal(2, task.priority)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('updates priority between non-zero values', function()
|
||||||
|
s:add({ description = 'Task name', priority = 2 })
|
||||||
|
s:save()
|
||||||
|
local lines = {
|
||||||
|
'# Inbox',
|
||||||
|
'/1/- [!] Task name',
|
||||||
|
}
|
||||||
|
diff.apply(lines, s)
|
||||||
|
s:load()
|
||||||
|
local task = s:get(1)
|
||||||
|
assert.are.equal(1, task.priority)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses metadata with forge ref on same line', function()
|
||||||
|
local lines = {
|
||||||
|
'# Inbox',
|
||||||
|
'- [ ] Fix bug due:2026-03-15 gh:user/repo#42',
|
||||||
|
}
|
||||||
|
diff.apply(lines, s)
|
||||||
|
s:load()
|
||||||
|
local task = s:get(1)
|
||||||
|
assert.are.equal('2026-03-15', task.due)
|
||||||
|
assert.is_not_nil(task._extra._forge_ref)
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,30 @@ describe('forge', function()
|
||||||
assert.is_nil(forge._parse_shorthand('xx:user/repo#1'))
|
assert.is_nil(forge._parse_shorthand('xx:user/repo#1'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('rejects missing number', function()
|
it('parses bare gh: shorthand without number', function()
|
||||||
assert.is_nil(forge._parse_shorthand('gh:user/repo'))
|
local ref = forge._parse_shorthand('gh:user/repo')
|
||||||
|
assert.is_not_nil(ref)
|
||||||
|
assert.equals('github', ref.forge)
|
||||||
|
assert.equals('user', ref.owner)
|
||||||
|
assert.equals('repo', ref.repo)
|
||||||
|
assert.equals('repo', ref.type)
|
||||||
|
assert.is_nil(ref.number)
|
||||||
|
assert.equals('https://github.com/user/repo', ref.url)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses bare gl: shorthand without number', function()
|
||||||
|
local ref = forge._parse_shorthand('gl:group/project')
|
||||||
|
assert.is_not_nil(ref)
|
||||||
|
assert.equals('gitlab', ref.forge)
|
||||||
|
assert.equals('group', ref.owner)
|
||||||
|
assert.equals('project', ref.repo)
|
||||||
|
assert.equals('repo', ref.type)
|
||||||
|
assert.is_nil(ref.number)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('rejects missing repo', function()
|
it('rejects missing repo', function()
|
||||||
assert.is_nil(forge._parse_shorthand('gh:user#1'))
|
assert.is_nil(forge._parse_shorthand('gh:user#1'))
|
||||||
|
assert.is_nil(forge._parse_shorthand('gh:user'))
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -73,6 +91,23 @@ describe('forge', function()
|
||||||
it('rejects non-github URL', function()
|
it('rejects non-github URL', function()
|
||||||
assert.is_nil(forge._parse_github_url('https://example.com/user/repo/issues/1'))
|
assert.is_nil(forge._parse_github_url('https://example.com/user/repo/issues/1'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('parses bare repo URL', function()
|
||||||
|
local ref = forge._parse_github_url('https://github.com/user/repo')
|
||||||
|
assert.is_not_nil(ref)
|
||||||
|
assert.equals('github', ref.forge)
|
||||||
|
assert.equals('user', ref.owner)
|
||||||
|
assert.equals('repo', ref.repo)
|
||||||
|
assert.equals('repo', ref.type)
|
||||||
|
assert.is_nil(ref.number)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses bare repo URL with trailing slash', function()
|
||||||
|
local ref = forge._parse_github_url('https://github.com/user/repo/')
|
||||||
|
assert.is_not_nil(ref)
|
||||||
|
assert.equals('repo', ref.type)
|
||||||
|
assert.is_nil(ref.number)
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
describe('_parse_gitlab_url', function()
|
describe('_parse_gitlab_url', function()
|
||||||
|
|
@ -98,6 +133,16 @@ describe('forge', function()
|
||||||
assert.equals('org/sub', ref.owner)
|
assert.equals('org/sub', ref.owner)
|
||||||
assert.equals('project', ref.repo)
|
assert.equals('project', ref.repo)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('parses bare repo URL', function()
|
||||||
|
local ref = forge._parse_gitlab_url('https://gitlab.com/group/project')
|
||||||
|
assert.is_not_nil(ref)
|
||||||
|
assert.equals('gitlab', ref.forge)
|
||||||
|
assert.equals('group', ref.owner)
|
||||||
|
assert.equals('project', ref.repo)
|
||||||
|
assert.equals('repo', ref.type)
|
||||||
|
assert.is_nil(ref.number)
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
describe('_parse_codeberg_url', function()
|
describe('_parse_codeberg_url', function()
|
||||||
|
|
@ -116,6 +161,16 @@ describe('forge', function()
|
||||||
assert.is_not_nil(ref)
|
assert.is_not_nil(ref)
|
||||||
assert.equals('pull_request', ref.type)
|
assert.equals('pull_request', ref.type)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('parses bare repo URL', function()
|
||||||
|
local ref = forge._parse_codeberg_url('https://codeberg.org/user/repo')
|
||||||
|
assert.is_not_nil(ref)
|
||||||
|
assert.equals('codeberg', ref.forge)
|
||||||
|
assert.equals('user', ref.owner)
|
||||||
|
assert.equals('repo', ref.repo)
|
||||||
|
assert.equals('repo', ref.type)
|
||||||
|
assert.is_nil(ref.number)
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
describe('parse_ref', function()
|
describe('parse_ref', function()
|
||||||
|
|
@ -141,6 +196,14 @@ describe('forge', function()
|
||||||
assert.is_nil(forge.parse_ref('hello'))
|
assert.is_nil(forge.parse_ref('hello'))
|
||||||
assert.is_nil(forge.parse_ref('due:tomorrow'))
|
assert.is_nil(forge.parse_ref('due:tomorrow'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('dispatches bare shorthand', function()
|
||||||
|
local ref = forge.parse_ref('gh:user/repo')
|
||||||
|
assert.is_not_nil(ref)
|
||||||
|
assert.equals('github', ref.forge)
|
||||||
|
assert.equals('repo', ref.type)
|
||||||
|
assert.is_nil(ref.number)
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
describe('find_refs', function()
|
describe('find_refs', function()
|
||||||
|
|
@ -184,6 +247,17 @@ describe('forge', function()
|
||||||
assert.equals(0, refs[1].start_byte)
|
assert.equals(0, refs[1].start_byte)
|
||||||
assert.equals(8, refs[1].end_byte)
|
assert.equals(8, refs[1].end_byte)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('finds bare shorthand ref', function()
|
||||||
|
local refs = forge.find_refs('Fix gh:user/repo')
|
||||||
|
assert.equals(1, #refs)
|
||||||
|
assert.equals('github', refs[1].ref.forge)
|
||||||
|
assert.equals('repo', refs[1].ref.type)
|
||||||
|
assert.is_nil(refs[1].ref.number)
|
||||||
|
assert.equals('gh:user/repo', refs[1].raw)
|
||||||
|
assert.equals(4, refs[1].start_byte)
|
||||||
|
assert.equals(16, refs[1].end_byte)
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
describe('_api_args', function()
|
describe('_api_args', function()
|
||||||
|
|
@ -262,6 +336,30 @@ describe('forge', function()
|
||||||
assert.equals('PendingForgeClosed', hl)
|
assert.equals('PendingForgeClosed', hl)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('formats bare repo ref without #N', function()
|
||||||
|
local text = forge.format_label({
|
||||||
|
forge = 'github',
|
||||||
|
owner = 'user',
|
||||||
|
repo = 'repo',
|
||||||
|
type = 'repo',
|
||||||
|
url = '',
|
||||||
|
}, nil)
|
||||||
|
assert.truthy(text:find('user/repo'))
|
||||||
|
assert.is_nil(text:find('#'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('still formats numbered ref with #N', function()
|
||||||
|
local text = forge.format_label({
|
||||||
|
forge = 'github',
|
||||||
|
owner = 'user',
|
||||||
|
repo = 'repo',
|
||||||
|
type = 'issue',
|
||||||
|
number = 42,
|
||||||
|
url = '',
|
||||||
|
}, nil)
|
||||||
|
assert.truthy(text:find('user/repo#42'))
|
||||||
|
end)
|
||||||
|
|
||||||
it('uses closed highlight for merged state', function()
|
it('uses closed highlight for merged state', function()
|
||||||
local _, hl = forge.format_label({
|
local _, hl = forge.format_label({
|
||||||
forge = 'gitlab',
|
forge = 'gitlab',
|
||||||
|
|
@ -306,7 +404,7 @@ describe('forge parse.body integration', function()
|
||||||
it('extracts category but keeps forge ref in description', function()
|
it('extracts category but keeps forge ref in description', function()
|
||||||
local desc, meta = parse.body('Fix bug gh:user/repo#42 cat:Work')
|
local desc, meta = parse.body('Fix bug gh:user/repo#42 cat:Work')
|
||||||
assert.equals('Fix bug gh:user/repo#42', desc)
|
assert.equals('Fix bug gh:user/repo#42', desc)
|
||||||
assert.equals('Work', meta.cat)
|
assert.equals('Work', meta.category)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('leaves non-forge tokens as description', function()
|
it('leaves non-forge tokens as description', function()
|
||||||
|
|
@ -330,7 +428,7 @@ describe('forge registry', function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('register() with custom backend resolves URLs', function()
|
it('register() with custom backend resolves URLs', function()
|
||||||
local custom = forge.gitea_backend({
|
local custom = forge.gitea_forge({
|
||||||
name = 'mygitea',
|
name = 'mygitea',
|
||||||
shorthand = 'mg',
|
shorthand = 'mg',
|
||||||
default_host = 'gitea.example.com',
|
default_host = 'gitea.example.com',
|
||||||
|
|
@ -367,8 +465,8 @@ describe('forge registry', function()
|
||||||
assert.same({ 'tea', 'api', '/repos/alice/proj/issues/7' }, args)
|
assert.same({ 'tea', 'api', '/repos/alice/proj/issues/7' }, args)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('gitea_backend() creates a working backend', function()
|
it('gitea_forge() creates a working backend', function()
|
||||||
local b = forge.gitea_backend({
|
local b = forge.gitea_forge({
|
||||||
name = 'forgejo',
|
name = 'forgejo',
|
||||||
shorthand = 'fj',
|
shorthand = 'fj',
|
||||||
default_host = 'forgejo.example.com',
|
default_host = 'forgejo.example.com',
|
||||||
|
|
@ -391,6 +489,99 @@ describe('forge registry', function()
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
describe('custom forge prefixes', function()
|
||||||
|
local config = require('pending.config')
|
||||||
|
local complete = require('pending.complete')
|
||||||
|
|
||||||
|
it('parses custom-length shorthand (3+ chars)', function()
|
||||||
|
local custom = forge.gitea_forge({
|
||||||
|
name = 'customforge',
|
||||||
|
shorthand = 'cgf',
|
||||||
|
default_host = 'custom.example.com',
|
||||||
|
})
|
||||||
|
forge.register(custom)
|
||||||
|
|
||||||
|
local ref = forge._parse_shorthand('cgf:alice/proj#99')
|
||||||
|
assert.is_not_nil(ref)
|
||||||
|
assert.equals('customforge', ref.forge)
|
||||||
|
assert.equals('alice', ref.owner)
|
||||||
|
assert.equals('proj', ref.repo)
|
||||||
|
assert.equals(99, ref.number)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parse_ref dispatches custom-length shorthand', function()
|
||||||
|
local ref = forge.parse_ref('cgf:alice/proj#5')
|
||||||
|
assert.is_not_nil(ref)
|
||||||
|
assert.equals('customforge', ref.forge)
|
||||||
|
assert.equals(5, ref.number)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('find_refs finds custom-length shorthand', function()
|
||||||
|
local refs = forge.find_refs('Fix cgf:alice/proj#12')
|
||||||
|
assert.equals(1, #refs)
|
||||||
|
assert.equals('customforge', refs[1].ref.forge)
|
||||||
|
assert.equals(12, refs[1].ref.number)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('completion returns entries for custom backends', function()
|
||||||
|
assert.is_true(complete._is_forge_source('cgf'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('config shorthand override re-registers backend', function()
|
||||||
|
vim.g.pending = {
|
||||||
|
forge = {
|
||||||
|
github = { shorthand = 'github' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
config.reset()
|
||||||
|
forge._reset_instances()
|
||||||
|
|
||||||
|
local ref = forge._parse_shorthand('github:user/repo#1')
|
||||||
|
assert.is_not_nil(ref)
|
||||||
|
assert.equals('github', ref.forge)
|
||||||
|
assert.equals('user', ref.owner)
|
||||||
|
assert.equals('repo', ref.repo)
|
||||||
|
assert.equals(1, ref.number)
|
||||||
|
|
||||||
|
assert.is_nil(forge._parse_shorthand('gh:user/repo#1'))
|
||||||
|
|
||||||
|
vim.g.pending = nil
|
||||||
|
config.reset()
|
||||||
|
for _, b in ipairs(forge.backends()) do
|
||||||
|
if b.name == 'github' then
|
||||||
|
b.shorthand = 'gh'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
forge._reset_instances()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('is_configured', function()
|
||||||
|
it('returns false when vim.g.pending is nil', function()
|
||||||
|
vim.g.pending = nil
|
||||||
|
assert.is_false(forge.is_configured('github'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns false when forge key is absent', function()
|
||||||
|
vim.g.pending = { forge = { close = true } }
|
||||||
|
assert.is_false(forge.is_configured('github'))
|
||||||
|
vim.g.pending = nil
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns true when forge key is present', function()
|
||||||
|
vim.g.pending = { forge = { github = {} } }
|
||||||
|
assert.is_true(forge.is_configured('github'))
|
||||||
|
assert.is_false(forge.is_configured('gitlab'))
|
||||||
|
vim.g.pending = nil
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns true for non-empty forge config', function()
|
||||||
|
vim.g.pending = { forge = { gitlab = { icon = '' } } }
|
||||||
|
assert.is_true(forge.is_configured('gitlab'))
|
||||||
|
vim.g.pending = nil
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
describe('forge diff integration', function()
|
describe('forge diff integration', function()
|
||||||
local store = require('pending.store')
|
local store = require('pending.store')
|
||||||
local diff = require('pending.diff')
|
local diff = require('pending.diff')
|
||||||
|
|
@ -449,4 +640,19 @@ describe('forge diff integration', function()
|
||||||
assert.equals(1, updated._extra._forge_ref.number)
|
assert.equals(1, updated._extra._forge_ref.number)
|
||||||
os.remove(tmp)
|
os.remove(tmp)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('stores bare forge_ref in _extra on new task', function()
|
||||||
|
local tmp = os.tmpname()
|
||||||
|
local s = store.new(tmp)
|
||||||
|
s:load()
|
||||||
|
diff.apply({ '- [ ] Check out gh:user/repo' }, s)
|
||||||
|
local tasks = s:active_tasks()
|
||||||
|
assert.equals(1, #tasks)
|
||||||
|
assert.is_not_nil(tasks[1]._extra)
|
||||||
|
assert.is_not_nil(tasks[1]._extra._forge_ref)
|
||||||
|
assert.equals('github', tasks[1]._extra._forge_ref.forge)
|
||||||
|
assert.equals('repo', tasks[1]._extra._forge_ref.type)
|
||||||
|
assert.is_nil(tasks[1]._extra._forge_ref.number)
|
||||||
|
os.remove(tmp)
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -31,27 +31,52 @@ describe('parse', function()
|
||||||
it('extracts category', function()
|
it('extracts category', function()
|
||||||
local desc, meta = parse.body('Buy groceries cat:Errands')
|
local desc, meta = parse.body('Buy groceries cat:Errands')
|
||||||
assert.are.equal('Buy groceries', desc)
|
assert.are.equal('Buy groceries', desc)
|
||||||
assert.are.equal('Errands', meta.cat)
|
assert.are.equal('Errands', meta.category)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('extracts both due and cat', function()
|
it('extracts both due and cat', function()
|
||||||
local desc, meta = parse.body('Buy milk due:2026-03-15 cat:Errands')
|
local desc, meta = parse.body('Buy milk due:2026-03-15 cat:Errands')
|
||||||
assert.are.equal('Buy milk', desc)
|
assert.are.equal('Buy milk', desc)
|
||||||
assert.are.equal('2026-03-15', meta.due)
|
assert.are.equal('2026-03-15', meta.due)
|
||||||
assert.are.equal('Errands', meta.cat)
|
assert.are.equal('Errands', meta.category)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('extracts metadata in any order', function()
|
it('extracts metadata in any order', function()
|
||||||
local desc, meta = parse.body('Buy milk cat:Errands due:2026-03-15')
|
local desc, meta = parse.body('Buy milk cat:Errands due:2026-03-15')
|
||||||
assert.are.equal('Buy milk', desc)
|
assert.are.equal('Buy milk', desc)
|
||||||
assert.are.equal('2026-03-15', meta.due)
|
assert.are.equal('2026-03-15', meta.due)
|
||||||
assert.are.equal('Errands', meta.cat)
|
assert.are.equal('Errands', meta.category)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('stops at duplicate key', function()
|
it('first occurrence wins for duplicate keys and warns', function()
|
||||||
|
local warnings = {}
|
||||||
|
local orig = vim.notify
|
||||||
|
vim.notify = function(m, level)
|
||||||
|
if level == vim.log.levels.WARN then
|
||||||
|
table.insert(warnings, m)
|
||||||
|
end
|
||||||
|
end
|
||||||
local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01')
|
local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01')
|
||||||
assert.are.equal('Buy milk due:2026-03-15', desc)
|
vim.notify = orig
|
||||||
assert.are.equal('2026-04-01', meta.due)
|
assert.are.equal('Buy milk', desc)
|
||||||
|
assert.are.equal('2026-03-15', meta.due)
|
||||||
|
assert.are.equal(1, #warnings)
|
||||||
|
assert.truthy(warnings[1]:find('duplicate', 1, true))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('drops identical duplicate metadata tokens and warns', function()
|
||||||
|
local warnings = {}
|
||||||
|
local orig = vim.notify
|
||||||
|
vim.notify = function(m, level)
|
||||||
|
if level == vim.log.levels.WARN then
|
||||||
|
table.insert(warnings, m)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local desc, meta = parse.body('Buy milk due:tomorrow due:tomorrow')
|
||||||
|
vim.notify = orig
|
||||||
|
assert.are.equal('Buy milk', desc)
|
||||||
|
assert.are.equal(os.date('%Y-%m-%d', os.time() + 86400), meta.due)
|
||||||
|
assert.are.equal(1, #warnings)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('stops at non-meta token', function()
|
it('stops at non-meta token', function()
|
||||||
|
|
@ -110,6 +135,66 @@ describe('parse', function()
|
||||||
assert.is_nil(meta.due)
|
assert.is_nil(meta.due)
|
||||||
assert.truthy(desc:find('due:garbage', 1, true))
|
assert.truthy(desc:find('due:garbage', 1, true))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('parses metadata before a forge ref', function()
|
||||||
|
local desc, meta = parse.body('Fix bug due:2026-03-15 gh:user/repo#42')
|
||||||
|
assert.are.equal('2026-03-15', meta.due)
|
||||||
|
assert.truthy(desc:find('gh:user/repo#42', 1, true))
|
||||||
|
assert.truthy(desc:find('Fix bug', 1, true))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses metadata after a forge ref', function()
|
||||||
|
local desc, meta = parse.body('Fix bug gh:user/repo#42 due:2026-03-15')
|
||||||
|
assert.are.equal('2026-03-15', meta.due)
|
||||||
|
assert.truthy(desc:find('gh:user/repo#42', 1, true))
|
||||||
|
assert.truthy(desc:find('Fix bug', 1, true))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses all metadata around forge ref', function()
|
||||||
|
local desc, meta = parse.body('Fix bug due:tomorrow gh:user/repo#42 cat:Work')
|
||||||
|
assert.are.equal(os.date('%Y-%m-%d', os.time() + 86400), meta.due)
|
||||||
|
assert.are.equal('Work', meta.category)
|
||||||
|
assert.truthy(desc:find('gh:user/repo#42', 1, true))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses forge ref between metadata tokens', function()
|
||||||
|
local desc, meta = parse.body('Fix bug cat:Work gl:a/b#12 due:2026-03-15')
|
||||||
|
assert.are.equal('2026-03-15', meta.due)
|
||||||
|
assert.are.equal('Work', meta.category)
|
||||||
|
assert.truthy(desc:find('gl:a/b#12', 1, true))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('extracts leading metadata', function()
|
||||||
|
local desc, meta = parse.body('due:2026-03-15 Fix the bug')
|
||||||
|
assert.are.equal('Fix the bug', desc)
|
||||||
|
assert.are.equal('2026-03-15', meta.due)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('extracts metadata from the middle', function()
|
||||||
|
local desc, meta = parse.body('Fix due:2026-03-15 the bug')
|
||||||
|
assert.are.equal('Fix the bug', desc)
|
||||||
|
assert.are.equal('2026-03-15', meta.due)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('extracts multiple metadata from any position', function()
|
||||||
|
local desc, meta = parse.body('cat:Work Fix due:2026-03-15 the bug')
|
||||||
|
assert.are.equal('Fix the bug', desc)
|
||||||
|
assert.are.equal('2026-03-15', meta.due)
|
||||||
|
assert.are.equal('Work', meta.category)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('extracts all metadata types from mixed positions', function()
|
||||||
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
local tomorrow = os.date(
|
||||||
|
'%Y-%m-%d',
|
||||||
|
os.time({ year = today.year, month = today.month, day = today.day + 1 })
|
||||||
|
)
|
||||||
|
local desc, meta = parse.body('due:tomorrow cat:Work Fix the bug +!')
|
||||||
|
assert.are.equal('Fix the bug', desc)
|
||||||
|
assert.are.equal(tomorrow, meta.due)
|
||||||
|
assert.are.equal('Work', meta.category)
|
||||||
|
assert.are.equal(1, meta.priority)
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
describe('parse.resolve_date', function()
|
describe('parse.resolve_date', function()
|
||||||
|
|
@ -400,7 +485,7 @@ describe('parse', function()
|
||||||
it('detects category prefix', function()
|
it('detects category prefix', function()
|
||||||
local desc, meta = parse.command_add('School: Do homework')
|
local desc, meta = parse.command_add('School: Do homework')
|
||||||
assert.are.equal('Do homework', desc)
|
assert.are.equal('Do homework', desc)
|
||||||
assert.are.equal('School', meta.cat)
|
assert.are.equal('School', meta.category)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('ignores lowercase prefix', function()
|
it('ignores lowercase prefix', function()
|
||||||
|
|
@ -411,7 +496,7 @@ describe('parse', function()
|
||||||
it('combines category prefix with inline metadata', function()
|
it('combines category prefix with inline metadata', function()
|
||||||
local desc, meta = parse.command_add('School: Do homework due:2026-03-15')
|
local desc, meta = parse.command_add('School: Do homework due:2026-03-15')
|
||||||
assert.are.equal('Do homework', desc)
|
assert.are.equal('Do homework', desc)
|
||||||
assert.are.equal('School', meta.cat)
|
assert.are.equal('School', meta.category)
|
||||||
assert.are.equal('2026-03-15', meta.due)
|
assert.are.equal('2026-03-15', meta.due)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ describe('recur', function()
|
||||||
local r = recur.parse('daily')
|
local r = recur.parse('daily')
|
||||||
assert.are.equal('daily', r.freq)
|
assert.are.equal('daily', r.freq)
|
||||||
assert.are.equal(1, r.interval)
|
assert.are.equal(1, r.interval)
|
||||||
assert.is_false(r.from_completion)
|
assert.are.equal('scheduled', r.mode)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('parses weekdays', function()
|
it('parses weekdays', function()
|
||||||
|
|
@ -79,7 +79,7 @@ describe('recur', function()
|
||||||
it('parses ! prefix as completion-based', function()
|
it('parses ! prefix as completion-based', function()
|
||||||
local r = recur.parse('!weekly')
|
local r = recur.parse('!weekly')
|
||||||
assert.are.equal('weekly', r.freq)
|
assert.are.equal('weekly', r.freq)
|
||||||
assert.is_true(r.from_completion)
|
assert.are.equal('completion', r.mode)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('parses raw RRULE fragment', function()
|
it('parses raw RRULE fragment', function()
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,100 @@ describe('sync', function()
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
describe('register_backend', function()
|
||||||
|
it('registers a custom backend', function()
|
||||||
|
pending.register_backend({ name = 'custom', pull = function() end })
|
||||||
|
local set = pending.sync_backend_set()
|
||||||
|
assert.is_true(set['custom'] == true)
|
||||||
|
assert.is_true(vim.tbl_contains(pending.sync_backends(), 'custom'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('rejects backend without name', function()
|
||||||
|
local msg
|
||||||
|
local orig = vim.notify
|
||||||
|
vim.notify = function(m, level)
|
||||||
|
if level == vim.log.levels.ERROR then
|
||||||
|
msg = m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
pending.register_backend({})
|
||||||
|
vim.notify = orig
|
||||||
|
assert.truthy(msg and msg:find('non%-empty'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('rejects backend with empty name', function()
|
||||||
|
local msg
|
||||||
|
local orig = vim.notify
|
||||||
|
vim.notify = function(m, level)
|
||||||
|
if level == vim.log.levels.ERROR then
|
||||||
|
msg = m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
pending.register_backend({ name = '' })
|
||||||
|
vim.notify = orig
|
||||||
|
assert.truthy(msg and msg:find('non%-empty'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('rejects duplicate of built-in 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.register_backend({ name = 'gcal' })
|
||||||
|
vim.notify = orig
|
||||||
|
assert.truthy(msg and msg:find('already exists'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('rejects duplicate registered backend', function()
|
||||||
|
pending.register_backend({ name = 'dup_test', pull = function() end })
|
||||||
|
local msg
|
||||||
|
local orig = vim.notify
|
||||||
|
vim.notify = function(m, level)
|
||||||
|
if level == vim.log.levels.ERROR then
|
||||||
|
msg = m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
pending.register_backend({ name = 'dup_test' })
|
||||||
|
vim.notify = orig
|
||||||
|
assert.truthy(msg and msg:find('already registered'))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('resolve_backend', function()
|
||||||
|
it('resolves built-in backend', function()
|
||||||
|
local mod = pending.resolve_backend('gcal')
|
||||||
|
assert.is_not_nil(mod)
|
||||||
|
assert.are.equal('gcal', mod.name)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves registered backend', function()
|
||||||
|
local custom = { name = 'resolve_test', pull = function() end }
|
||||||
|
pending.register_backend(custom)
|
||||||
|
local mod = pending.resolve_backend('resolve_test')
|
||||||
|
assert.is_not_nil(mod)
|
||||||
|
assert.are.equal('resolve_test', mod.name)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns nil for unknown backend', function()
|
||||||
|
assert.is_nil(pending.resolve_backend('nonexistent_xyz'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('dispatches command to registered backend', function()
|
||||||
|
local called = false
|
||||||
|
pending.register_backend({
|
||||||
|
name = 'cmd_test',
|
||||||
|
pull = function()
|
||||||
|
called = true
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
pending.command('cmd_test pull')
|
||||||
|
assert.is_true(called)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
describe('auto-discovery', function()
|
describe('auto-discovery', function()
|
||||||
it('discovers gcal and gtasks backends', function()
|
it('discovers gcal and gtasks backends', function()
|
||||||
local backends = pending.sync_backends()
|
local backends = pending.sync_backends()
|
||||||
|
|
|
||||||
|
|
@ -450,5 +450,83 @@ describe('views', function()
|
||||||
end
|
end
|
||||||
assert.is_nil(task_meta.recur)
|
assert.is_nil(task_meta.recur)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('sorts by due before priority when sort config is reordered', function()
|
||||||
|
vim.g.pending = {
|
||||||
|
data_path = tmpdir .. '/tasks.json',
|
||||||
|
view = { queue = { sort = { 'status', 'due', 'priority', 'order', 'id' } } },
|
||||||
|
}
|
||||||
|
config.reset()
|
||||||
|
s:add({ description = 'High no due', category = 'Work', priority = 2 })
|
||||||
|
s:add({ description = 'Low with due', category = 'Work', priority = 0, due = '2050-01-01' })
|
||||||
|
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
|
||||||
|
if lines[i]:find('Low with due') then
|
||||||
|
due_row = i
|
||||||
|
elseif lines[i]:find('High no due') then
|
||||||
|
nodue_row = i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert.is_true(due_row < nodue_row)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('uses default sort when config sort is nil', function()
|
||||||
|
vim.g.pending = {
|
||||||
|
data_path = tmpdir .. '/tasks.json',
|
||||||
|
view = { queue = {} },
|
||||||
|
}
|
||||||
|
config.reset()
|
||||||
|
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
|
||||||
|
if lines[i]:find('High') then
|
||||||
|
high_row = i
|
||||||
|
elseif lines[i]:find('Low') then
|
||||||
|
low_row = i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert.is_true(high_row < low_row)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('ignores unknown sort keys with a warning', function()
|
||||||
|
vim.g.pending = {
|
||||||
|
data_path = tmpdir .. '/tasks.json',
|
||||||
|
view = { queue = { sort = { 'bogus', 'status', 'id' } } },
|
||||||
|
}
|
||||||
|
config.reset()
|
||||||
|
s:add({ description = 'A', category = 'Work' })
|
||||||
|
s:add({ description = 'B', category = 'Work' })
|
||||||
|
local lines = views.priority_view(s:active_tasks())
|
||||||
|
assert.is_true(#lines == 2)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('supports age sort key as alias for id', function()
|
||||||
|
vim.g.pending = {
|
||||||
|
data_path = tmpdir .. '/tasks.json',
|
||||||
|
view = { queue = { sort = { 'age' } } },
|
||||||
|
}
|
||||||
|
config.reset()
|
||||||
|
s:add({ description = 'Older', category = 'Work' })
|
||||||
|
s:add({ description = 'Newer', category = 'Work' })
|
||||||
|
local lines, meta = views.priority_view(s:active_tasks())
|
||||||
|
local older_row, newer_row
|
||||||
|
for i, m in ipairs(meta) do
|
||||||
|
if m.type == 'task' then
|
||||||
|
if lines[i]:find('Older') then
|
||||||
|
older_row = i
|
||||||
|
elseif lines[i]:find('Newer') then
|
||||||
|
newer_row = i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert.is_true(older_row < newer_row)
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue