Compare commits

..

33 Commits

Author SHA1 Message Date
7215b4969b Re-design output to make skimming easier.
- We now always protect the left margin when printing task details
  (including tags) to make it easier to skim down that line.
- Also made the actual summary always follow immediately after the ID,
  to align to that skimmable line.
- Moved the information about the delegatee to the end of the summary,
  next to the tags, and changed the color of the delegatee to make it
  easier to distinguish.
- Added the `-G` option, to allow filtering out issues matching any of
  the provided tags.
- We now allow options to be passed to both the `delegate` and `help`
  command. Any options are ignored, but this allows the use of tools
  like `cmd_shell` which always wrap commands with the pre-given
  options.
2022-07-31 20:01:39 -05:00
c7891de310 Show Pending in either Today's list of the Future list, but not both at the same time. 2022-07-28 10:48:51 -05:00
a373af0658 Add support for filtering based on property exclusion.
For example, allow commands like:

  # exclude issues from context "abc"
  pit list -C abc

  # exclude issues delegated to John Doe
  pit list -P "delgated-to:John Doe"
2022-07-22 10:39:13 -05:00
de3ee05680 Change logging format. 2022-01-22 10:31:58 -06:00
59440d2c9d Remove unused, old copies of usage and outline text. 2022-01-21 15:01:07 -06:00
6226ff21c5 Fix compile-time message includes when installing via Nimble. 2022-01-21 14:59:22 -06:00
71e035fdbe Add --show-hidden to ignore hide-until properties. 2022-01-04 22:27:08 -06:00
df854f864c When completing an issue, print info about any new issue created by recurrence. 2021-09-21 10:14:49 -05:00
7bccd83e23 Add support for issue recurrence. 2021-09-17 13:51:37 -05:00
b25d2be164 Adding more comrehensive documentaition regaring issue types and properties. 2021-09-16 23:31:12 -05:00
e0ab3cb401 Include Pending in the 'today' view. Allow listing multiple issues or states. 2021-08-30 11:49:35 -05:00
d93c0cf348 Add the ability to hide tasks until a certain date. 2021-08-24 10:56:47 -05:00
9606e71cec Add support for binary properties via data URIs. 2021-06-07 18:34:41 -05:00
98f4dda1ad Reset terminal attributes after printing single issues. 2021-02-01 07:41:01 -06:00
393be347c9 Default list command no longer includes Done issues. 2020-11-12 14:50:43 -06:00
f8fed9d937 Allow filtering by issue tags. 2020-11-12 07:37:22 -06:00
ef16eafd48 Update to address changes for Nim 1.x (currently 1.4.0). 2020-11-12 04:05:04 -06:00
4af0d09356 Remove stuff in the README that I don't understand. 🤷 2020-10-02 12:33:51 -05:00
071c4b66e5 Explicitly document list contexts subcommand. Sort listed contexts alphabetically. 2020-10-02 12:13:22 -05:00
57a3af4f2f When moving issues, create target directories if they do not already exist. 2020-07-06 11:47:31 -05:00
08b9df2086 Update to reflect changes in Nim stdlib in v1.2. 2020-07-06 11:39:57 -05:00
339e88cddd list contexts command now prints both the listeral context value and the display name. 2020-05-05 09:40:08 -05:00
0a2249018b Add ptk integration option as a command-line switch. 2020-04-16 10:22:56 -05:00
ec3008937d Add README, bugfix CLI configuration. 2020-03-23 09:18:44 -05:00
10fcc34ea2 Update nim-cli-util dependency version to get a fix to the queryParamsToCli function. 2020-03-23 08:27:30 -05:00
4127fbe41c Better PTK integration.
- Now includes the context as a PTK tag (if present).
- Add the PIT ID to the PTK notes.
2020-03-16 09:39:17 -05:00
0671d7728e Add helper to update version easily. 2020-02-16 00:57:58 -06:00
7b5f26f24a Update dependency references to use full URLs to non-central libs. 2020-02-14 11:21:54 -06:00
db3e648d47 Add tag and untag commands. 2019-04-18 07:43:08 -05:00
476a94c679 Add property removal behavior: specifying a property with no value removes it. 2019-01-24 22:30:20 -06:00
65edc56e08 Add delegate command. 2019-01-18 18:51:51 -06:00
d4db66a71e Updates to compile on Nim 0.19 2019-01-17 13:18:25 -06:00
f8ccc831ef WIP Updates to compile on Nim 0.19. 2019-01-17 11:02:46 -06:00
7 changed files with 773 additions and 164 deletions

138
README.md Normal file
View File

@ -0,0 +1,138 @@
# Personal Issue Tracker
This is [Jonathan Bernard's](mailto:jonathan@jdbernard.com) personal issue
tracker. In it's current form it is essentially a way to keep an curated list of
TODO's, organizing them by workflow category (todo, todo-today, dormant, etc.)
and context (Personal, Work, etc.).
## Categories
`pit` organizes issues into the following workflow categories:
- `current` - actively in progress
- `todo` - to be addressed in the future
- `todo-today` - chosen to be addressed today
- `pending` - blocked by some third party
- `dormant` - long-term things I don't want to forget but don't need in front
of me every day.
- `done`
In my typical workflow the `todo` category serves as a collection point for
things I want to keep track of. Then on a a daily basis I review issues in the
`todo` category and move a selection to the `todo-today` category. I also try
to keep the total number of issues in the `todo` below about a dozen. If there
are more than a dozen things in my `todo` category I will identify the lowest
priority items and move them to the `dormant` category.
## Issue Properties
`pit` allows arbitrary properties to be attached to issues in the form of
key-value pairs. On the command line these can be provided via the `-p` or
`--properties` parameter in the form
`-p <prop1Name>:<prop1Value>;<prop2Name>:<prop2Value>[;...]`
There are a couple of properties that pit will recognize automatically:
- `context`: the context organization feature is implemented using issue
properties.
- `created`: `pit` uses this property to timestamp an issue when it is created.
- `completed`: `pit` uses this property to timestamp an issue when it is moved
to the `done` category.
- `pending`: `pit` looks to this property to provide extra information about
issues in the `pending` category. Typically I use this to note who or what is
blocking the issue and why.
Some other common properties I use are:
- `resolution`: for short notes about why an issue was moved to `done`,
especially if it the action wasn't taken or if it is not completely clear
that this issue was completed.
## Configuration Options
`pit` allows configuration via command-line options and via a configuration
file. There is some overlap between the two methods of configuring `pit`, but
it is not a complete mapping.
### Config File
`pit` looks for a JSON configuration file in the following places (in order):
1. From a file path passed on the command line via the `--config <cfgFile>` parameter,
2. `./.pitrc`, in the current working directory,
3. From a file path set in the `PITRC` environment variable.
4. `$HOME/.pitrc`, in the user's home directory.
#### Sample Config File
This example illustrates all of the possible configuration options.
```json
{
"api": {
"apiKeys": [
"50cdcb660554e2d50fd88bd40b6579717bf00643f6ff57f108baf16c8c083f77",
"e4fc1aac49fc1f2f7f4ca6b1f04d41a4ccdd58e13bb53b41da97703d47267ceb",
]
},
"cli": {
"defaultContext": "personal",
"verbose": false,
"termWidth": 120,
"triggerPtk": true
},
"contexts": {
"nla-music": "New Life Music",
"nla-youth-band": "New Life Youth Band",
"acn": "Accenture",
"hff": "Hope Family Fellowship"
},
"tasksDir": "/mnt/c/Users/Jonathan Bernard/synced/tasks"
}
```
#### Explanation of configurable options.
In general, options supplied on the CLI directly will override options supplied
in the configuration file. All options are optional unless stated otherwise.
* `api`: configuration options specific to the API service.
- `apiKeys`: a list of Bearer tokens accepted by the API for the purpose of
authenticating API requests.
* `cli`: configuration options specific to the CLI.
- `defaultContext`: if present all invokations to the CLI will
be in this context. This is like adding a `--context <defaultContext>`
parameter to every CLI invocation. Any actual `--context` parameter will
override this value.
- `verbose`: Show issue details when listing issues (same as
`--verbose` flag).
- `termWidth`: Set the expected width of the terminal (for wrapping text).
- `triggerPtk`: If set to `true`, invoke the `ptk` command to start and stop
timers when issues move to the `current` and `done` categories
respectively.
* `contexts`: `pit` allows issues to be organized into different contexts via
a `context` property on the issue. The CLI groups issues according to
context. When printing contexts the CLI will take the value from the issues'
`context` properties and capatalize it. In some cases you may wish to have a
different display value for a context. I like to use abbreviations for long
context names to reduce the need to type, `hff` for "Hope Family Fellowship",
for example. The `contexts` config option allows you to provide a map of
context values to context display names See the sample file below for an
example.
Note that this mapping does not have to have entries for all contexts, only
those you wish to provide with an alternate display form. For example, in the
configuration sample above the default context is `personal`, a value not
present in the `contexts` configuration. `personal` will be displayed as
"Personal"; it does not need an alternate display name.
* `tasksDir` **required**: a file path to the root directory for the issue
repository (same as `--tasks-dir` CLI parameter).

View File

@ -1,8 +1,6 @@
# Package # Package
include "src/pitpkg/version.nim" version = "4.21.0"
version = PIT_VERSION
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Personal issue tracker." description = "Personal issue tracker."
license = "MIT" license = "MIT"
@ -11,5 +9,18 @@ bin = @["pit", "pit_api"]
# Dependencies # Dependencies
requires @[ "nim >= 0.18.0", "cliutils 0.5.0", "docopt 0.6.5", "jester 0.2.0", requires @[
"langutils >= 0.4.0", "timeutils 0.3.0", "uuids 0.1.9" ] "nim >= 1.4.0",
"docopt 0.6.8",
"jester 0.5.0",
"uuids 0.1.10",
"https://git.jdb-software.com/jdb/nim-cli-utils.git >= 0.6.4",
"https://git.jdb-software.com/jdb/nim-lang-utils.git >= 0.4.0",
"https://git.jdb-software.com/jdb/nim-time-utils.git >= 0.4.0",
"https://git.jdb-software.com/jdb/nim-data-uri.git >= 1.0.0",
"https://git.jdb-software.com/jdb/update-nim-package-version >= 0.2.0"
]
task updateVersion, "Update the version of this package.":
exec "update_nim_package_version pit 'src/pitpkg/cliconstants.nim'"

View File

@ -1,16 +1,16 @@
## Personal Issue Tracker CLI interface ## Personal Issue Tracker CLI interface
## ==================================== ## ====================================
import cliutils, docopt, json, logging, options, os, ospaths, sequtils, import std/algorithm, std/logging, std/options, std/os, std/sequtils,
tables, terminal, times, timeutils, unicode, uuids std/wordwrap, std/tables, std/terminal, std/times, std/unicode
import cliutils, data_uri, docopt, json, timeutils, uuids
from nre import re from nre import re
import strutils except capitalize, toUpper, toLower import strutils except alignLeft, capitalize, strip, toUpper, toLower
import pitpkg/private/libpit import pitpkg/private/libpit
import pitpkg/cliconstants
export libpit export libpit
include "pitpkg/version.nim"
type type
CliContext = ref object CliContext = ref object
cfg*: PitConfig cfg*: PitConfig
@ -36,6 +36,7 @@ proc initContext(args: Table[string, Value]): CliContext =
let cliCfg = CombinedConfig(docopt: args, json: cliJson) let cliCfg = CombinedConfig(docopt: args, json: cliJson)
result = CliContext( result = CliContext(
cfg: pitCfg,
contexts: pitCfg.contexts, contexts: pitCfg.contexts,
defaultContext: defaultContext:
if not cliJson.hasKey("defaultContext"): none(string) if not cliJson.hasKey("defaultContext"): none(string)
@ -43,12 +44,12 @@ proc initContext(args: Table[string, Value]): CliContext =
verbose: parseBool(cliCfg.getVal("verbose", "false")) and not args["--quiet"], verbose: parseBool(cliCfg.getVal("verbose", "false")) and not args["--quiet"],
issues: newTable[IssueState, seq[Issue]](), issues: newTable[IssueState, seq[Issue]](),
tasksDir: pitCfg.tasksDir, tasksDir: pitCfg.tasksDir,
termWidth: parseInt(cliCfg.getVal("term-width", "80")), termWidth: parseInt(cliCfg.getVal("termWidth", "80")),
triggerPtk: cliJson.getOrDefault("triggerPtk").getBool(false)) triggerPtk: cliJson.getOrDefault("triggerPtk").getBool(false))
proc getIssueContextDisplayName(ctx: CliContext, context: string): string = proc getIssueContextDisplayName(ctx: CliContext, context: string): string =
if not ctx.contexts.hasKey(context): if not ctx.contexts.hasKey(context):
if context.isNilOrWhitespace: return "<default>" if context.isEmptyOrWhitespace: return "<default>"
else: return context.capitalize() else: return context.capitalize()
return ctx.contexts[context] return ctx.contexts[context]
@ -66,39 +67,73 @@ proc formatIssue(ctx: CliContext, issue: Issue): string =
result &= "--------".withColor(fgBlack, true) & "\n" result &= "--------".withColor(fgBlack, true) & "\n"
if not issue.details.isNilOrWhitespace: if not issue.details.isEmptyOrWhitespace:
result &= issue.details.strip.withColor(fgCyan) & "\n" result &= issue.details.strip.withColor(fgCyan) & "\n"
proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "", result &= termReset
proc formatSectionIssue(
ctx: CliContext,
issue: Issue,
width: int,
indent = "",
verbose = false): string = verbose = false): string =
result = "" result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true) & " "
var showDetails = not issue.details.isNilOrWhitespace and verbose let showDetails = not issue.details.isEmptyOrWhitespace and verbose
# Wrap and write the summary. let summaryIndentLen = indent.len + 7
var wrappedSummary = (" ".repeat(5) & issue.summary).wordWrap(width - 2).indent(2 + indent.len) let summaryWidth = width - summaryIndentLen
wrappedSummary = wrappedSummary[(6 + indent.len)..^1]
result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true) let summaryLines = issue.summary
result &= wrappedSummary.withColor(fgWhite) .wrapWords(summaryWidth)
.splitLines
result &= summaryLines[0].withColor(fgWhite)
for line in summaryLines[1..^1]:
result &= "\p" & line.indent(summaryIndentLen)
var lastLineLen = summaryLines[^1].len
if issue.hasProp("delegated-to"):
if lastLineLen + issue["delegated-to"].len + 1 < summaryWidth:
result &= " " & issue["delegated-to"].withColor(fgMagenta)
lastLineLen += issue["delegated-to"].len + 1
else:
result &= "\p" & issue["delegated-to"]
.withColor(fgMagenta)
.indent(summaryIndentLen)
lastLineLen = issue["delegated-to"].len
if issue.tags.len > 0: if issue.tags.len > 0:
let tagsStr = "(" & issue.tags.join(", ") & ")" let tagsStrLines = ("(" & issue.tags.join(", ") & ")")
if (result.splitLines[^1].len + tagsStr.len + 1) > (width - 2): .wrapWords(summaryWidth)
result &= "\n" & indent .splitLines
result &= " " & tagsStr.withColor(fgGreen)
if tagsStrLines.len == 1 and
(lastLineLen + tagsStrLines[0].len + 1) < summaryWidth:
result &= " " & tagsStrLines[0].withColor(fgGreen)
lastLineLen += tagsStrLines[0].len + 1
else:
result &= "\p" & tagsStrLines
.mapIt(it.indent(summaryIndentLen))
.join("\p")
.withColor(fgGreen)
lastLineLen = tagsStrLines[^1].len
if issue.hasProp("pending"): if issue.hasProp("pending"):
let startIdx = "Pending: ".len result &= "\p" & ("Pending: " & issue["pending"])
var pendingText = issue["pending"].wordWrap(width - startIdx - 2) .wrapwords(summaryWidth)
.indent(startIdx) .withColor(fgCyan)
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2) .indent(summaryIndentLen)
result &= "\n" & pendingText.withColor(fgCyan)
if showDetails: if showDetails:
result &= "\n" & issue.details.strip.indent(indent.len + 2).withColor(fgCyan) result &= "\p" & issue.details
.strip
.withColor(fgBlack, bright = true)
.indent(summaryIndentLen)
result &= termReset result &= termReset
@ -106,16 +141,9 @@ proc formatSectionIssueList(ctx: CliContext, issues: seq[Issue], width: int,
indent: string, verbose: bool): string = indent: string, verbose: bool): string =
result = "" result = ""
var topPadded = true
for i in issues: for i in issues:
var issueText = ctx.formatSectionIssue(i, width, indent, verbose) var issueText = ctx.formatSectionIssue(i, width, indent, verbose)
if issueText.splitLines.len > 1:
if topPadded: result &= issueText & "\n\n"
else: result &= "\n" & issueText & "\n\n"
topPadded = true
else:
result &= issueText & "\n" result &= issueText & "\n"
topPadded = false
proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState, proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
indent = "", verbose = false): string = indent = "", verbose = false): string =
@ -143,6 +171,10 @@ proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
proc loadIssues(ctx: CliContext, state: IssueState) = proc loadIssues(ctx: CliContext, state: IssueState) =
ctx.issues[state] = loadIssues(ctx.tasksDir / $state) ctx.issues[state] = loadIssues(ctx.tasksDir / $state)
proc loadOpenIssues(ctx: CliContext) =
ctx.issues = newTable[IssueState, seq[Issue]]()
for state in [Current, TodoToday, Todo, Pending, Todo]: ctx.loadIssues(state)
proc loadAllIssues(ctx: CliContext) = proc loadAllIssues(ctx: CliContext) =
ctx.issues = newTable[IssueState, seq[Issue]]() ctx.issues = newTable[IssueState, seq[Issue]]()
for state in IssueState: ctx.loadIssues(state) for state in IssueState: ctx.loadIssues(state)
@ -158,6 +190,16 @@ proc parsePropertiesOption(propsOpt: string): TableRef[string, string] =
if pair.len == 1: result[pair[0]] = "true" if pair.len == 1: result[pair[0]] = "true"
else: result[pair[0]] = pair[1] else: result[pair[0]] = pair[1]
proc parseExclPropertiesOption(propsOpt: string): TableRef[string, seq[string]] =
result = newTable[string, seq[string]]()
for propText in propsOpt.split(";"):
let pair = propText.split(":", 1)
let val =
if pair.len == 1: "true"
else: pair[1]
if result.hasKey(pair[0]): result[pair[0]].add(val)
else: result[pair[0]] = @[val]
proc sameDay(a, b: DateTime): bool = proc sameDay(a, b: DateTime): bool =
result = a.year == b.year and a.yearday == b.yearday result = a.year == b.year and a.yearday == b.yearday
@ -186,22 +228,40 @@ proc edit(issue: Issue) =
let editedIssue = loadIssue(issue.filepath) let editedIssue = loadIssue(issue.filepath)
editedIssue.store() editedIssue.store()
except: except:
fatal "pit: updated issue is invalid (ignoring edits): \n\t" & fatal "updated issue is invalid (ignoring edits): \n\t" &
getCurrentExceptionMsg() getCurrentExceptionMsg()
issue.store() issue.store()
proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState], showToday, showFuture, verbose: bool) = proc list(
ctx: CliContext,
filter: Option[IssueFilter],
states: Option[seq[IssueState]],
showToday, showFuture,
showHidden = false,
verbose: bool) =
if state.isSome: if states.isSome:
ctx.loadIssues(state.get) trace "listing issues for " & $states.get
for state in states.get:
ctx.loadIssues(state)
if filter.isSome: ctx.filterIssues(filter.get) if filter.isSome: ctx.filterIssues(filter.get)
stdout.write ctx.formatSection(ctx.issues[state.get], state.get, "", verbose)
# Show Done for just today if requested
if state == Done and showToday:
ctx.issues[Done] = ctx.issues[Done].filterIt(
it.hasProp("completed") and
sameDay(getTime().local, it.getDateTime("completed")))
stdout.write ctx.formatSection(ctx.issues[state], state, "", verbose)
trace "listing complete"
return return
ctx.loadAllIssues() ctx.loadOpenIssues()
if filter.isSome: ctx.filterIssues(filter.get) if filter.isSome:
ctx.filterIssues(filter.get)
trace "filtered issues"
let today = showToday and [Current, TodoToday].anyIt( let today = showToday and [Current, TodoToday, Pending].anyIt(
ctx.issues.hasKey(it) and ctx.issues[it].len > 0) ctx.issues.hasKey(it) and ctx.issues[it].len > 0)
let future = showFuture and [Pending, Todo].anyIt( let future = showFuture and [Pending, Todo].anyIt(
@ -213,91 +273,63 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState
if today: if today:
if future: ctx.writeHeader("Today") if future: ctx.writeHeader("Today")
for s in [Current, TodoToday]: for s in [Current, TodoToday, Pending]:
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0: if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
stdout.write ctx.formatSection(ctx.issues[s], s, indent, verbose) let visibleIssues = ctx.issues[s].filterIt(
showHidden or
not (it.hasProp("hide-until") and
it.getDateTime("hide-until") > getTime().local))
if ctx.issues.hasKey(Done): stdout.write ctx.formatSection(visibleIssues, s, indent, verbose)
let doneIssues = ctx.issues[Done].filterIt(
it.hasProp("completed") and
sameDay(getTime().local, it.getDateTime("completed")))
if doneIssues.len > 0:
stdout.write ctx.formatSection(doneIssues, Done, indent, verbose)
# Future items # Future items
if future: if future:
if today: ctx.writeHeader("Future") if today: ctx.writeHeader("Future")
for s in [Pending, Todo]: let futureCategories =
if showToday: @[Todo]
else: @[Pending, Todo]
for s in futureCategories:
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0: if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
stdout.write ctx.formatSection(ctx.issues[s], s, indent, verbose) let visibleIssues = ctx.issues[s].filterIt(
showHidden or
not (it.hasProp("hide-until") and
it.getDateTime("hide-until") > getTime().local))
stdout.write ctx.formatSection(visibleIssues, s, indent, verbose)
trace "listing complete"
when isMainModule: when isMainModule:
try: try:
let doc = """
Usage:
pit ( new | add) <summary> [<state>] [options]
pit list [<listable>] [options]
pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options]
pit edit <ref>...
pit reorder <state>
pit ( delete | rm ) <id>...
Options: let consoleLogger = newConsoleLogger(
levelThreshold=lvlInfo,
-h, --help Print this usage information. fmtStr="pit - $levelname: ")
logging.addHandler(consoleLogger)
-p, --properties <props> Specify properties. Formatted as "key:val;key:val"
When used with the list command this option applies
a filter to the issues listed, only allowing those
which have all of the given properties.
-c, --context <ctxName> Shorthand for '-p context:<ctxName>'
-g, --tags <tags> Specify tags for an issue.
-T, --today Limit to today's issues.
-F, --future Limit to future issues.
-m, --match <pattern> Limit to issues whose summaries match the given
pattern (PCRE regex supported).
-M, --match-all <pat> Limit to the issues whose summaries or details
match the given pattern (PCRE regex supported).
-v, --verbose Show issue details when listing issues.
-q, --quiet Suppress verbose output.
-y, --yes Automatically answer "yes" to any prompts.
-C, --config <cfgFile> Location of the config file (defaults to $HOME/.pitrc)
-E, --echo-args Echo arguments (for debug purposes).
-d, --tasks-dir Path to the tasks directory (defaults to the value
configured in the .pitrc file)
--term-width <width> Manually set the terminal width to use.
"""
logging.addHandler(newConsoleLogger())
# Parse arguments # Parse arguments
let args = docopt(doc, version = PIT_VERSION) let args = docopt(USAGE, version = PIT_VERSION)
if args["--debug"]:
consoleLogger.levelThreshold = lvlDebug
if args["--echo-args"]: stderr.writeLine($args) if args["--echo-args"]: stderr.writeLine($args)
if args["--help"]: if args["help"]:
stderr.writeLine(doc) stderr.writeLine(USAGE & "\n")
stderr.writeLine(ONLINE_HELP)
quit() quit()
let ctx = initContext(args) let ctx = initContext(args)
trace "context initiated"
var propertiesOption = none(TableRef[string,string]) var propertiesOption = none(TableRef[string,string])
var exclPropsOption = none(TableRef[string,seq[string]])
var tagsOption = none(seq[string]) var tagsOption = none(seq[string])
var exclTagsOption = none(seq[string])
if args["--properties"] or args["--context"]: if args["--properties"] or args["--context"]:
@ -309,8 +341,25 @@ Options:
propertiesOption = some(props) propertiesOption = some(props)
if args["--excl-properties"] or args["--excl-context"]:
var exclProps =
if args["--excl-properties"]:
parseExclPropertiesOption($args["--excl-properties"])
else: newTable[string,seq[string]]()
if args["--excl-context"]:
if not exclProps.hasKey("context"): exclProps["context"] = @[]
let exclContexts = split($args["--excl-context"], ",")
exclProps["context"] = exclProps["context"].concat(exclContexts)
exclPropsOption = some(exclProps)
if args["--tags"]: tagsOption = some(($args["--tags"]).split(",").mapIt(it.strip)) if args["--tags"]: tagsOption = some(($args["--tags"]).split(",").mapIt(it.strip))
if args["--excl-tags"]: exclTagsOption =
some(($args["--excl-tags"]).split(",").mapIt(it.strip))
## Actual command runners ## Actual command runners
if args["new"] or args["add"]: if args["new"] or args["add"]:
let state = let state =
@ -328,7 +377,7 @@ Options:
summary: $args["<summary>"], summary: $args["<summary>"],
properties: issueProps, properties: issueProps,
tags: tags:
if args["--tags"]: ($args["--tags"]).split(",").mapIt(it.strip) if tagsOption.isSome: tagsOption.get
else: newSeq[string]()) else: newSeq[string]())
ctx.tasksDir.store(issue, state) ctx.tasksDir.store(issue, state)
@ -353,6 +402,29 @@ Options:
else: edit(ctx.tasksDir.loadIssueById(editRef)) else: edit(ctx.tasksDir.loadIssueById(editRef))
elif args["tag"]:
if tagsOption.isNone: raise newException(Exception, "no tags given")
let newTags = tagsOption.get
for id in @(args["<id>"]):
var issue = ctx.tasksDir.loadIssueById(id)
issue.tags = deduplicate(issue.tags & newTags)
issue.store()
elif args["untag"]:
let tagsToRemove: seq[string] =
if tagsOption.isSome: tagsOption.get
else: @[]
for id in @(args["<id>"]):
var issue = ctx.tasksDir.loadIssueById(id)
if tagsToRemove.len > 0:
issue.tags = issue.tags.filter(
proc (tag: string): bool = not tagsToRemove.anyIt(it == tag))
else: issue.tags = @[]
issue.store()
elif args["start"] or args["todo-today"] or args["done"] or elif args["start"] or args["todo-today"] or args["done"] or
args["pending"] or args["todo"] or args["suspend"]: args["pending"] or args["todo"] or args["suspend"]:
@ -369,19 +441,48 @@ Options:
if propertiesOption.isSome: if propertiesOption.isSome:
for k,v in propertiesOption.get: for k,v in propertiesOption.get:
issue[k] = v issue[k] = v
if targetState == Done: issue["completed"] = getTime().local.formatIso8601 if targetState == Done:
issue["completed"] = getTime().local.formatIso8601
if issue.hasProp("recurrence") and issue.getRecurrence.isSome:
let nextIssue = ctx.tasksDir.nextRecurrence(issue.getRecurrence.get, issue)
ctx.tasksDir.store(nextIssue, Todo)
info "created the next recurrence:"
stdout.writeLine ctx.formatIssue(nextIssue)
issue.changeState(ctx.tasksDir, targetState) issue.changeState(ctx.tasksDir, targetState)
if ctx.triggerPtk: if ctx.triggerPtk or args["--ptk"]:
if targetState == Current: if targetState == Current:
let issue = ctx.tasksDir.loadIssueById($(args["<id>"][0])) let issue = ctx.tasksDir.loadIssueById($(args["<id>"][0]))
var cmd = "ptk start " var cmd = "ptk start"
if issue.tags.len > 0: cmd &= "-g \"" & issue.tags.join(",") & "\"" if issue.tags.len > 0 or issue.hasProp("context"):
let tags = concat(
issue.tags,
if issue.hasProp("context"): @[issue.properties["context"]]
else: @[]
)
cmd &= " -g \"" & tags.join(",") & "\""
cmd &= " -n \"pit-id: " & $issue.id & "\""
cmd &= " \"" & issue.summary & "\"" cmd &= " \"" & issue.summary & "\""
discard execShellCmd(cmd) discard execShellCmd(cmd)
elif targetState == Done or targetState == Pending: elif targetState == Done or targetState == Pending:
discard execShellCmd("ptk stop") discard execShellCmd("ptk stop")
elif args["hide-until"]:
let issue = ctx.tasksDir.loadIssueById($(args["<id>"]))
issue.setDateTime("hide-until", parseDate($args["<date>"]))
issue.store()
elif args["delegate"]:
let issue = ctx.tasksDir.loadIssueById($(args["<id>"]))
issue["delegated-to"] = $args["<delegated-to>"]
issue.store()
elif args["delete"] or args["rm"]: elif args["delete"] or args["rm"]:
for id in @(args["<id>"]): for id in @(args["<id>"]):
@ -404,6 +505,11 @@ Options:
filter.properties = propertiesOption.get filter.properties = propertiesOption.get
filterOption = some(filter) filterOption = some(filter)
# Add property exclusions (if given)
if exclPropsOption.isSome:
filter.exclProperties = exclPropsOption.get
filterOption = some(filter)
# If they supplied text matches, add that to the filter. # If they supplied text matches, add that to the filter.
if args["--match"]: if args["--match"]:
filter.summaryMatch = some(re("(?i)" & $args["--match"])) filter.summaryMatch = some(re("(?i)" & $args["--match"]))
@ -419,21 +525,29 @@ Options:
filter.properties["context"] = ctx.defaultContext.get filter.properties["context"] = ctx.defaultContext.get
filterOption = some(filter) filterOption = some(filter)
if tagsOption.isSome:
filter.hasTags = tagsOption.get
filterOption = some(filter)
if exclTagsOption.isSome:
filter.exclTags = exclTagsOption.get
filterOption = some(filter)
# Finally, if the "context" is "all", don't filter on context # Finally, if the "context" is "all", don't filter on context
if filter.properties.hasKey("context") and if filter.properties.hasKey("context") and
filter.properties["context"] == "all": filter.properties["context"] == "all":
filter.properties.del("context") filter.properties.del("context")
filter.exclProperties.del("context")
var listContexts = false var listContexts = false
var stateOption = none(IssueState) var statesOption = none(seq[IssueState])
var issueIdOption = none(string) var issueIdsOption = none(seq[string])
if args["<listable>"]: if args["contexts"]: listContexts = true
if $args["<listable>"] == "contexts": listContexts = true elif args["<stateOrId>"]:
else: try: statesOption = some(args["<stateOrId>"].mapIt(parseEnum[IssueState]($it)))
try: stateOption = some(parseEnum[IssueState]($args["<listable>"])) except: issueIdsOption = some(args["<stateOrId>"].mapIt($it))
except: issueIdOption = some($args["<listable>"])
# List the known contexts # List the known contexts
if listContexts: if listContexts:
@ -444,21 +558,60 @@ Options:
if issue.hasProp("context") and not uniqContexts.contains(issue["context"]): if issue.hasProp("context") and not uniqContexts.contains(issue["context"]):
uniqContexts.add(issue["context"]) uniqContexts.add(issue["context"])
for c in uniqContexts: stdout.writeLine(c) let maxLen = foldl(uniqContexts,
if a.len > b.len: a
else: b
).len
for c in uniqContexts.sorted:
stdout.writeLine(c.alignLeft(maxLen+2) & ctx.getIssueContextDisplayName(c))
# List a specific issue # List a specific issue
elif issueIdOption.isSome: elif issueIdsOption.isSome:
let issue = ctx.tasksDir.loadIssueById(issueIdOption.get) for issueId in issueIdsOption.get:
let issue = ctx.tasksDir.loadIssueById(issueId)
stdout.writeLine ctx.formatIssue(issue) stdout.writeLine ctx.formatIssue(issue)
# List all issues # List all issues
else: else:
trace "listing all issues"
let showBoth = args["--today"] == args["--future"] let showBoth = args["--today"] == args["--future"]
ctx.list(filterOption, stateOption, showBoth or args["--today"], ctx.list(
showBoth or args["--future"], filter = filterOption,
ctx.verbose) states = statesOption,
showToday = showBoth or args["--today"],
showFuture = showBoth or args["--future"],
showHidden = args["--show-hidden"],
verbose = ctx.verbose)
elif args["add-binary-property"]:
let issue = ctx.tasksDir.loadIssueById($(args["<id>"]))
let propIn =
if $(args["<propSource>"]) == "-": stdin
else: open($(args["<propSource>"]))
try: issue[$(args["<propName>"])] = encodeAsDataUri(readAll(propIn))
finally: close(propIn)
issue.store()
elif args["get-binary-property"]:
let issue = ctx.tasksDir.loadIssueById($(args["<id>"]))
if not issue.hasProp($(args["<propName>"])):
raise newException(Exception,
"issue " & ($issue.id)[0..<6] & " has no property name '" &
$(args["<propName>"]) & "'")
let propOut =
if $(args["<propDest>"]) == "-": stdout
else: open($(args["<propDest>"]), fmWrite)
try: write(propOut, decodeDataUri(issue[$(args["<propName>"])]))
finally: close(propOut)
except: except:
fatal "pit: " & getCurrentExceptionMsg() fatal getCurrentExceptionMsg()
#raise getCurrentException() #raise getCurrentException()
quit(QuitFailure) quit(QuitFailure)

View File

@ -1,12 +1,11 @@
## Personal Issue Tracker API Interface ## Personal Issue Tracker API Interface
## ==================================== ## ====================================
import asyncdispatch, cliutils, docopt, jester, json, logging, sequtils, strutils import asyncdispatch, cliutils, docopt, jester, json, logging, options, sequtils, strutils
import nre except toSeq import nre except toSeq
import pitpkg/private/libpit import pitpkg/private/libpit
import pitpkg/cliconstants
include "pitpkg/version.nim"
type type
PitApiCfg* = object PitApiCfg* = object
@ -18,6 +17,20 @@ const TXT = "text/plain"
proc raiseEx(reason: string): void = raise newException(Exception, reason) proc raiseEx(reason: string): void = raise newException(Exception, reason)
template halt(code: HttpCode,
headers: RawHeaders,
content: string): void =
## Immediately replies with the specified request. This means any further
## code will not be executed after calling this template in the current
## route.
bind TCActionSend, newHttpHeaders
result[0] = CallbackAction.TCActionSend
result[1] = code
result[2] = some(headers)
result[3] = content
result.matched = true
break allRoutes
template checkAuth(cfg: PitApiCfg) = template checkAuth(cfg: PitApiCfg) =
## Check this request for authentication and authorization information. ## Check this request for authentication and authorization information.
## If the request is not authorized, this template sets up the 401 response ## If the request is not authorized, this template sets up the 401 response
@ -26,10 +39,10 @@ template checkAuth(cfg: PitApiCfg) =
var authed {.inject.} = false var authed {.inject.} = false
try: try:
if not request.headers.hasKey("Authorization"): if not headers(request).hasKey("Authorization"):
raiseEx "No auth token." raiseEx "No auth token."
let headerVal = request.headers["Authorization"] let headerVal = headers(request)["Authorization"]
if not headerVal.startsWith("Bearer "): if not headerVal.startsWith("Bearer "):
raiseEx "Invalid Authentication type (only 'Bearer' is supported)." raiseEx "Invalid Authentication type (only 'Bearer' is supported)."
@ -40,11 +53,10 @@ template checkAuth(cfg: PitApiCfg) =
except: except:
stderr.writeLine "Auth failed: " & getCurrentExceptionMsg() stderr.writeLine "Auth failed: " & getCurrentExceptionMsg()
response.data[0] = CallbackAction.TCActionSend halt(
response.data[1] = Http401 Http401,
response.data[2]["WWW-Authenticate"] = "Bearer" @{"Content-Type": TXT},
response.data[2]["Content-Type"] = TXT getCurrentExceptionMsg())
response.data[3] = getCurrentExceptionMsg()
proc start*(cfg: PitApiCfg) = proc start*(cfg: PitApiCfg) =
@ -60,7 +72,7 @@ proc start*(cfg: PitApiCfg) =
resp("pong", TXT) resp("pong", TXT)
get "/issues": get "/issues":
checkAuth(cfg); if not authed: return true checkAuth(cfg)
var args = queryParamsToCliArgs(request.params) var args = queryParamsToCliArgs(request.params)
args = @["list"] & args args = @["list"] & args
@ -71,10 +83,10 @@ proc start*(cfg: PitApiCfg) =
else: resp(stripAnsi(execResult[0]), TXT) else: resp(stripAnsi(execResult[0]), TXT)
post "/issues": post "/issues":
checkAuth(cfg); if not authed: return true checkAuth(cfg)
get "/issue/@issueId": get "/issue/@issueId":
checkAuth(cfg); if not authed: return true checkAuth(cfg)
var args = queryParamsToCliArgs(request.params) var args = queryParamsToCliArgs(request.params)
args = @["list", @"issueId"] & args args = @["list", @"issueId"] & args

175
src/pitpkg/cliconstants.nim Normal file
View File

@ -0,0 +1,175 @@
const PIT_VERSION* = "4.21.0"
const USAGE* = """Usage:
pit ( new | add) <summary> [<state>] [options]
pit list contexts [options]
pit list [<stateOrId>...] [options]
pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options]
pit edit <ref>... [options]
pit tag <id>... [options]
pit untag <id>... [options]
pit reorder <state> [options]
pit delegate <id> <delegated-to> [options]
pit hide-until <id> <date> [options]
pit ( delete | rm ) <id>... [options]
pit add-binary-property <id> <propName> <propSource> [options]
pit get-binary-property <id> <propName> <propDest> [options]
pit help [options]
Options:
-h, --help Print this usage and help information.
-p, --properties <props> Specify properties. Formatted as "key:val;key:val"
When used with the list command this option applies
a filter to the issues listed, only allowing those
which have all of the given properties.
-P, --excl-properties <props>
When used with the list command, exclude issues
that contain properties with the given value. This
parameter is formatted the same as the --properties
parameter: "key:val;key:val"
-c, --context <ctx> Shorthand for '-p context:<ctx>'
-C, --excl-context <ctx> Don't show issues from the given context(s).
Multiple contexts can be excluded using a ',' to
separate names. For example: -C ctx1,ctx2
Shorthand for '-P context:<ctx>'
-g, --tags <tags> Specify tags for an issue. Tags are specified as a
comma-delimited list. For example: -g tag1,tag2
-G, --excl-tags <tags> When used with the list command, exclude issues
that contain any of the provided tags. Tags are
specified as a comma-delimited list.
For example: -G tag1,tag2
-T, --today Limit to today's issues.
-F, --future Limit to future issues.
-H, --show-hidden Show all matching issues, ignoring any 'hide-until'
properties set.
-m, --match <pattern> Limit to issues whose summaries match the given
pattern (PCRE regex supported).
-M, --match-all <pat> Limit to the issues whose summaries or details
match the given pattern (PCRE regex supported).
-v, --verbose Show issue details when listing issues.
-q, --quiet Suppress verbose output.
-y, --yes Automatically answer "yes" to any prompts.
--config <cfgFile> Location of the config file (defaults to $HOME/.pitrc)
-E, --echo-args Echo arguments (for debug purposes).
-d, --tasks-dir Path to the tasks directory (defaults to the value
configured in the .pitrc file)
--term-width <width> Manually set the terminal width to use.
--ptk Enable PTK integration for this command.
--debug Enable debug-level log output.
"""
const ONLINE_HELP* = """Issue States:
PIT organizes issues around their state, which is one of:
current - issues actively being worked
todo-today - issues planned for today
pending - issues that are blocked by some third-party
done - issues that have been completely resolved
todo - issues that need to be done in the future
dormant - issues that are low-priority, to be tracked, but hidden
by default
Issue Properties:
PIT supports adding arbitrary properties to any issue to track any metadata
about the issue the user may wish. There are several properties that have
special behavior attached to them. They are:
created
If present, expected to be an ISO 8601-formatted date that represents the
time when the issue was created.
completed
If present, expected to be an ISO 8601-formatted date that represents the
time when the issue moved to the "done" state. PIT will add this
property automatically when you use the "done" command, and can filter on
this value.
context
Allows issues to be organized into contexts. The -c option is short-hand
for '-p context:<context-name>' and the 'list contexts' command will show
all values of 'context' set in existing issues.
delegated-to
When an issue now belongs to someone else, but needs to be monitored for
completion, this allows you to keep the issue in its current state but
note how it has been delegated. When present PIT will prepend this value
to the issue summary with an accent color.
hide-until
When present, expected to be an ISO 8601-formatted date and used to
supress the display of the issue until on or after the given date.
pending
When an issue is blocked by a third party, this property can be used to
capture details about the dependency When present PIT will display this
value after the issue summary.
recurrence
When an issue is moved to the "done" state, if the issue has a valid
"recurrence" property, PIT will create a new issue and set the
"hide-until" property for that new issue depending on the recurrence
definition.
A valid recurrence value has a time value and optionally has an source
issue ID. For example:
every 5 days, 10a544
The first word, "every", is expected to be either "every" or "after".
The second portion is expected to be a time period. Supported time units
are "hour", "day", "week", "month", an "year", along with the plural
forms (e.g. "5 days", "8 hours", etc.).
The final portion is the source issue ID. This is optional. When a source
issue ID is given, the new issue is created as a clone of the given
issue. When not given, the issue being closed is used for cloning.
The "every" and "after" keywords allow the user to choose whether the new
issue is created based on the creation time ("every") or the completion
time ("after") of the issue being closed based.
Examples:
every day
every 2 days
after 2 days
every week
after 12 hours
every 2 weeks, 10a544
tags
If present, expected to be a comma-delimited list of text tags. The -g
option is a short-hand for '-p tags:<tags-value>'.
"""

View File

@ -1,7 +1,8 @@
import cliutils, docopt, json, logging, langutils, options, os, ospaths, import std/json, std/logging, std/options, std/os, std/sequtils, std/strformat,
sequtils, strutils, tables, times, timeutils, uuids std/strutils, std/tables, std/times
import cliutils, docopt, langutils, timeutils, uuids
from nre import find, match, re, Regex import nre except toSeq
type type
Issue* = ref object Issue* = ref object
@ -22,16 +23,38 @@ type
IssueFilter* = ref object IssueFilter* = ref object
completedRange*: Option[tuple[b, e: DateTime]] completedRange*: Option[tuple[b, e: DateTime]]
fullMatch*, summaryMatch*: Option[Regex] fullMatch*, summaryMatch*: Option[Regex]
hasTags*: seq[string]
exclTags*: seq[string]
properties*: TableRef[string, string] properties*: TableRef[string, string]
exclProperties*: TableRef[string, seq[string]]
PitConfig* = ref object PitConfig* = ref object
tasksDir*: string tasksDir*: string
contexts*: TableRef[string, string] contexts*: TableRef[string, string]
cfg*: CombinedConfig cfg*: CombinedConfig
Recurrence* = object
cloneId*: Option[string]
interval*: TimeInterval
isFromCompletion*: bool
const DONE_FOLDER_FORMAT* = "yyyy-MM" const DONE_FOLDER_FORMAT* = "yyyy-MM"
let ISSUE_FILE_PATTERN = re"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}\.txt" let ISSUE_FILE_PATTERN = re"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}\.txt"
let RECURRENCE_PATTERN = re"(every|after) ((\d+) )?((hour|day|week|month|year)s?)(, ([0-9a-fA-F]+))?"
let traceStartTime = cpuTime()
var lastTraceTime = traceStartTime
proc trace*(msg: string, diffFromLast = false) =
let curTraceTime = cpuTime()
if diffFromLast:
debug &"{(curTraceTime - lastTraceTime) * 1000:6.2f}ms {msg}"
else:
debug &"{cpuTime() - traceStartTime:08.4f} {msg}"
lastTraceTime = curTraceTime
proc displayName*(s: IssueState): string = proc displayName*(s: IssueState): string =
case s case s
@ -49,6 +72,7 @@ proc `[]`*(issue: Issue, key: string): string =
proc `[]=`*(issue: Issue, key: string, value: string) = proc `[]=`*(issue: Issue, key: string, value: string) =
issue.properties[key] = value issue.properties[key] = value
## Issue property accessors
proc hasProp*(issue: Issue, key: string): bool = proc hasProp*(issue: Issue, key: string): bool =
return issue.properties.hasKey(key) return issue.properties.hasKey(key)
@ -62,12 +86,40 @@ proc getDateTime*(issue: Issue, key: string, default: DateTime): DateTime =
proc setDateTime*(issue: Issue, key: string, dt: DateTime) = proc setDateTime*(issue: Issue, key: string, dt: DateTime) =
issue.properties[key] = dt.formatIso8601 issue.properties[key] = dt.formatIso8601
proc getRecurrence*(issue: Issue): Option[Recurrence] =
if not issue.hasProp("recurrence"): return none[Recurrence]()
let m = issue["recurrence"].match(RECURRENCE_PATTERN)
if not m.isSome:
warn "not a valid recurrence value: '" & issue["recurrence"] & "'"
return none[Recurrence]()
let c = nre.toSeq(m.get.captures)
let timeVal = if c[2].isSome: c[2].get.parseInt
else: 1
return some(Recurrence(
isFromCompletion: c[0].get == "after",
interval:
case c[4].get:
of "hour": hours(timeVal)
of "day": days(timeVal)
of "week": weeks(timeVal)
of "month": months(timeVal)
of "year": years(timeVal)
else: weeks(1),
cloneId: c[6]))
## Issue filtering
proc initFilter*(): IssueFilter = proc initFilter*(): IssueFilter =
result = IssueFilter( result = IssueFilter(
completedRange: none(tuple[b, e: DateTime]), completedRange: none(tuple[b, e: DateTime]),
fullMatch: none(Regex), fullMatch: none(Regex),
summaryMatch: none(Regex), summaryMatch: none(Regex),
properties: newTable[string, string]()) hasTags: @[],
exclTags: @[],
properties: newTable[string, string](),
exclProperties: newTable[string,seq[string]]())
proc propsFilter*(props: TableRef[string, string]): IssueFilter = proc propsFilter*(props: TableRef[string, string]): IssueFilter =
if isNil(props): if isNil(props):
@ -89,6 +141,10 @@ proc fullMatchFilter*(pattern: string): IssueFilter =
result = initFilter() result = initFilter()
result.fullMatch = some(re("(?i)" & pattern)) result.fullMatch = some(re("(?i)" & pattern))
proc hasTagsFilter*(tags: seq[string]): IssueFilter =
result = initFilter()
result.hasTags = tags
proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] = proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] =
result = newTable[string, seq[Issue]]() result = newTable[string, seq[Issue]]()
for i in issues: for i in issues:
@ -97,6 +153,23 @@ proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Iss
result[key].add(i) result[key].add(i)
## Parse and format dates
const DATE_FORMATS = [
"MM/dd",
"MM-dd",
"yyyy-MM-dd",
"yyyy/MM/dd",
"yyyy-MM-dd'T'hh:mm:ss"
]
proc parseDate*(d: string): DateTime =
var errMsg = ""
for df in DATE_FORMATS:
try: return d.parse(df)
except:
errMsg &= "\n\tTried " & df & " with " & d
continue
raise newException(ValueError, "Unable to parse input as a date: " & d & errMsg)
## Parse and format issues ## Parse and format issues
proc fromStorageFormat*(id: string, issueTxt: string): Issue = proc fromStorageFormat*(id: string, issueTxt: string): Issue =
type ParseState = enum ReadingSummary, ReadingProps, ReadingDetails type ParseState = enum ReadingSummary, ReadingProps, ReadingDetails
@ -120,7 +193,7 @@ proc fromStorageFormat*(id: string, issueTxt: string): Issue =
of ReadingProps: of ReadingProps:
# Ignore empty lines # Ignore empty lines
if line.isNilOrWhitespace: continue if line.isEmptyOrWhitespace: continue
# Look for the sentinal to start parsing as detail lines # Look for the sentinal to start parsing as detail lines
if line == "--------": if line == "--------":
@ -146,9 +219,10 @@ proc toStorageFormat*(issue: Issue, withComments = false): string =
if withComments: lines.add("# Summary (one line):") if withComments: lines.add("# Summary (one line):")
lines.add(issue.summary) lines.add(issue.summary)
if withComments: lines.add("# Properties (\"key:value\" per line):") if withComments: lines.add("# Properties (\"key:value\" per line):")
for key, val in issue.properties: lines.add(key & ": " & val) for key, val in issue.properties:
if not val.isEmptyOrWhitespace: lines.add(key & ": " & val)
if issue.tags.len > 0: lines.add("tags: " & issue.tags.join(",")) if issue.tags.len > 0: lines.add("tags: " & issue.tags.join(","))
if not isNilOrWhitespace(issue.details) or withComments: if not isEmptyOrWhitespace(issue.details) or withComments:
if withComments: lines.add("# Details go below the \"--------\"") if withComments: lines.add("# Details go below the \"--------\"")
lines.add("--------") lines.add("--------")
lines.add(issue.details) lines.add(issue.details)
@ -167,6 +241,7 @@ proc loadIssueById*(tasksDir, id: string): Issue =
raise newException(KeyError, "cannot find issue for id: " & id) raise newException(KeyError, "cannot find issue for id: " & id)
proc store*(issue: Issue, withComments = false) = proc store*(issue: Issue, withComments = false) =
discard existsOrCreateDir(issue.filePath.parentDir)
writeFile(issue.filepath, toStorageFormat(issue, withComments)) writeFile(issue.filepath, toStorageFormat(issue, withComments))
proc store*(tasksDir: string, issue: Issue, state: IssueState, withComments = false) = proc store*(tasksDir: string, issue: Issue, state: IssueState, withComments = false) =
@ -178,7 +253,7 @@ proc store*(tasksDir: string, issue: Issue, state: IssueState, withComments = fa
else: else:
issue.filepath = stateDir / filename issue.filepath = stateDir / filename
issue.store() issue.store(withComments)
proc storeOrder*(issues: seq[Issue], path: string) = proc storeOrder*(issues: seq[Issue], path: string) =
var orderLines = newSeq[string]() var orderLines = newSeq[string]()
@ -194,12 +269,14 @@ proc storeOrder*(issues: seq[Issue], path: string) =
proc loadIssues*(path: string): seq[Issue] = proc loadIssues*(path: string): seq[Issue] =
let orderFile = path / "order.txt" let orderFile = path / "order.txt"
trace "loading issues under " & path
let orderedIds = let orderedIds =
if fileExists(orderFile): if fileExists(orderFile):
toSeq(orderFile.lines) toSeq(orderFile.lines)
.mapIt(it.split(' ')[0]) .mapIt(it.split(' ')[0])
.deduplicate .deduplicate
.filterIt(not it.startsWith("> ") and not it.isNilOrWhitespace) .filterIt(not it.startsWith("> ") and not it.isEmptyOrWhitespace)
else: newSeq[string]() else: newSeq[string]()
type TaggedIssue = tuple[issue: Issue, ordered: bool] type TaggedIssue = tuple[issue: Issue, ordered: bool]
@ -209,6 +286,7 @@ proc loadIssues*(path: string): seq[Issue] =
if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome(): if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome():
unorderedIssues.add((loadIssue(path), false)) unorderedIssues.add((loadIssue(path), false))
trace "loaded " & $unorderedIssues.len & " issues", true
result = @[] result = @[]
# Add all ordered issues in order # Add all ordered issues in order
@ -223,6 +301,8 @@ proc loadIssues*(path: string): seq[Issue] =
if taggedIssue.ordered: continue if taggedIssue.ordered: continue
result.add(taggedIssue.issue) result.add(taggedIssue.issue)
trace "ordered the loaded issues", true
# Finally, save current order # Finally, save current order
result.storeOrder(path) result.storeOrder(path)
@ -234,6 +314,37 @@ proc changeState*(issue: Issue, tasksDir: string, newState: IssueState) =
proc delete*(issue: Issue) = removeFile(issue.filepath) proc delete*(issue: Issue) = removeFile(issue.filepath)
proc nextRecurrence*(tasksDir: string, rec: Recurrence, defaultIssue: Issue): Issue =
let baseIssue = if rec.cloneId.isSome: tasksDir.loadIssueById(rec.cloneId.get)
else: defaultIssue
let newProps = newTable[string,string]()
for k, v in baseIssue.properties:
if k != "completed": newProps[k] = v
newProps["prev-recurrence"] = $baseIssue.id
result = Issue(
id: genUUID(),
summary: baseIssue.summary,
properties: newProps,
tags: baseIssue.tags)
let now = getTime().local
let startDate =
if rec.isFromCompletion:
if baseIssue.hasProp("completed"): baseIssue.getDateTime("completed")
else: now
else:
if baseIssue.hasProp("created"): baseIssue.getDateTime("created")
else: now
# walk the calendar until the next recurrence that is after the current time.
var nextTime = startDate + rec.interval
while now > nextTime: nextTime += rec.interval
result.setDateTime("hide-until", nextTime)
## Utilities for working with issue collections. ## Utilities for working with issue collections.
proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] = proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
result = issues result = issues
@ -241,6 +352,12 @@ proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
for k,v in filter.properties: for k,v in filter.properties:
result = result.filterIt(it.hasProp(k) and it[k] == v) result = result.filterIt(it.hasProp(k) and it[k] == v)
for k,v in filter.exclProperties:
result = result.filter(proc (iss: Issue): bool =
not iss.hasProp(k) or
not v.anyIt(it == iss[k])
)
if filter.completedRange.isSome: if filter.completedRange.isSome:
let range = filter.completedRange.get let range = filter.completedRange.get
result = result.filterIt( result = result.filterIt(
@ -255,6 +372,12 @@ proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
let p = filter.fullMatch.get let p = filter.fullMatch.get
result = result.filterIt( it.summary.find(p).isSome or it.details.find(p).isSome) result = result.filterIt( it.summary.find(p).isSome or it.details.find(p).isSome)
for tag in filter.hasTags:
result = result.filterIt(it.tags.find(tag) >= 0)
for exclTag in filter.exclTags:
result = result.filterIt(it.tags.find(exclTag) < 0)
### Configuration utilities ### Configuration utilities
proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitConfig = proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitConfig =
let pitrcLocations = @[ let pitrcLocations = @[
@ -262,17 +385,17 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
".pitrc", $getEnv("PITRC"), $getEnv("HOME") & "/.pitrc"] ".pitrc", $getEnv("PITRC"), $getEnv("HOME") & "/.pitrc"]
var pitrcFilename: string = var pitrcFilename: string =
foldl(pitrcLocations, if len(a) > 0: a elif existsFile(b): b else: "") foldl(pitrcLocations, if len(a) > 0: a elif fileExists(b): b else: "")
if not existsFile(pitrcFilename): if not fileExists(pitrcFilename):
warn "pit: could not find .pitrc file: " & pitrcFilename warn "could not find .pitrc file: " & pitrcFilename
if isNilOrWhitespace(pitrcFilename): if isEmptyOrWhitespace(pitrcFilename):
pitrcFilename = $getEnv("HOME") & "/.pitrc" pitrcFilename = $getEnv("HOME") & "/.pitrc"
var cfgFile: File var cfgFile: File
try: try:
cfgFile = open(pitrcFilename, fmWrite) cfgFile = open(pitrcFilename, fmWrite)
cfgFile.write("{\"tasksDir\": \"/path/to/tasks\"}") cfgFile.write("{\"tasksDir\": \"/path/to/tasks\"}")
except: warn "pit: could not write default .pitrc to " & pitrcFilename except: warn "could not write default .pitrc to " & pitrcFilename
finally: close(cfgFile) finally: close(cfgFile)
var cfgJson: JsonNode var cfgJson: JsonNode
@ -292,15 +415,13 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
for k, v in cfgJson["contexts"]: for k, v in cfgJson["contexts"]:
result.contexts[k] = v.getStr() result.contexts[k] = v.getStr()
if isNilOrWhitespace(result.tasksDir): if isEmptyOrWhitespace(result.tasksDir):
raise newException(Exception, "no tasks directory configured") raise newException(Exception, "no tasks directory configured")
if not existsDir(result.tasksDir): if not dirExists(result.tasksDir):
raise newException(Exception, "cannot find tasks dir: " & result.tasksDir) raise newException(Exception, "cannot find tasks dir: " & result.tasksDir)
# Create our tasks directory structure if needed # Create our tasks directory structure if needed
for s in IssueState: for s in IssueState:
if not existsDir(result.tasksDir / $s): if not dirExists(result.tasksDir / $s):
(result.tasksDir / $s).createDir (result.tasksDir / $s).createDir

View File

@ -1 +0,0 @@
const PIT_VERSION = "4.4.2"