Compare commits

...

29 Commits
4.0.4 ... 4.9.4

Author SHA1 Message Date
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
93a0a15f12 Refactored to move HTTP query params to CLI arguments translation into the cliutils package. 2018-10-01 21:39:35 -05:00
dc31d590a0 Add GET /issue/<issueId> API endpoint. 2018-10-01 11:22:48 -04:00
8b46cc19d8 Rename variable to avoid overloading the name. 2018-10-01 11:22:31 -04:00
567c2d2178 Fix a bug when asking to move an issue to the state it's already in. 2018-06-25 11:40:25 -05:00
08dfbde57f Add the ability to order issues. 2018-06-11 12:11:26 -05:00
a924d7b649 Add filters for text-matching on issue summary or details. 2018-06-11 10:19:10 -05:00
2404f6a3d1 Add the ability to edit all issues in a given state. 2018-06-06 09:43:31 -05:00
2b5f82203c Add list contexts, refactor display logics.
* Refactor formatting logic to better calculate needed padding between
  issues and sections.
* Add `list contexts` command to list all known contexts according to
  the contexts configuration and the contexts defined in issues.
* Be more intentional about when the default context is used. Don't
  override existing context values in issues when changing their state.
* `edit` now allows multiple issues to be edited.
* Change single-issue display to be more verbose, listing all the
  properties and tags on an issue.
2018-05-29 14:24:18 -05:00
29959a6a8d Add REST API. Refactor config logic.
The REST API is simply a wrapper around the command line (and actually
invokes the command line). It relies on the command line tool validating
its input.

Currently only the `/list` endpoint is implemented, exposing the `list`
command.
2018-05-18 16:06:58 -05:00
6f247032a3 Add created property when creating issues. 2018-05-14 17:17:47 -05:00
efd5f6adff Add versbose flag, list specific issue. 2018-05-14 12:21:05 -05:00
49c5753ef1 Add rm as an alias for delete. 2018-05-14 10:09:33 -05:00
7 changed files with 740 additions and 180 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
*.sw* *.sw*
nimcache/ nimcache/
/pit /pit
/pit_api

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,12 +1,25 @@
# Package # Package
version = "4.0.4" version = "4.9.4"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Personal issue tracker." description = "Personal issue tracker."
license = "MIT" license = "MIT"
srcDir = "src" srcDir = "src"
bin = @["pit"] bin = @["pit", "pit_api"]
# Dependencies # Dependencies
requires @["nim >= 0.18.0", "uuids 0.1.9", "docopt 0.6.5", "cliutils 0.3.4", "timeutils 0.3.0"] requires @[
"nim >= 1.4.0",
"docopt 0.6.8",
"jester 0.5.0",
"uuids 0.1.10",
"https://git.jdb-labs.com/jdb/nim-cli-utils.git >= 0.6.4",
"https://git.jdb-labs.com/jdb/nim-lang-utils.git >= 0.4.0",
"https://git.jdb-labs.com/jdb/nim-time-utils.git >= 0.4.0",
"https://git.jdb-labs.com/jdb/update-nim-package-version"
]
task updateVersion, "Update the version of this package.":
exec "update_nim_package_version pit 'src/pitpkg/version.nim'"

View File

@ -1,141 +1,148 @@
## Personal Issue Tracker ## Personal Issue Tracker CLI interface
## ====================== ## ====================================
##
import cliutils, docopt, json, logging, options, os, ospaths, sequtils, import algorithm, cliutils, docopt, json, logging, options, os, sequtils,
tables, terminal, times, unicode, uuids std/wordwrap, tables, terminal, times, timeutils, unicode, uuids
import strutils except capitalize, toUpper, toLower from nre import re
import strutils except alignLeft, capitalize, strip, toUpper, toLower
import pitpkg/private/libpit import pitpkg/private/libpit
export libpit export libpit
include "pitpkg/version.nim"
type type
CliContext = ref object CliContext = ref object
autoList, triggerPtk: bool cfg*: PitConfig
tasksDir*: string
contexts*: TableRef[string, string] contexts*: TableRef[string, string]
defaultContext*: Option[string]
tasksDir*: string
issues*: TableRef[IssueState, seq[Issue]] issues*: TableRef[IssueState, seq[Issue]]
termWidth*: int termWidth*: int
triggerPtk*, verbose*: bool
let EDITOR =
if existsEnv("EDITOR"): getEnv("EDITOR")
else: "vi"
proc initContext(args: Table[string, Value]): CliContext = proc initContext(args: Table[string, Value]): CliContext =
let pitrcLocations = @[ let pitCfg = loadConfig(args)
if args["--config"]: $args["--config"] else: "",
".pitrc", $getEnv("PITRC"), $getEnv("HOME") & "/.pitrc"]
var pitrcFilename: string = let cliJson =
foldl(pitrcLocations, if len(a) > 0: a elif existsFile(b): b else: "") if pitCfg.cfg.json.hasKey("cli"): pitCfg.cfg.json["cli"]
else: newJObject()
if not existsFile(pitrcFilename): let cliCfg = CombinedConfig(docopt: args, json: cliJson)
warn "pit: could not find .pitrc file: " & pitrcFilename
if isNilOrWhitespace(pitrcFilename):
pitrcFilename = $getEnv("HOME") & "/.pitrc"
var cfgFile: File
try:
cfgFile = open(pitrcFilename, fmWrite)
cfgFile.write("{\"tasksDir\": \"/path/to/tasks\"}")
except: warn "pit: could not write default .pitrc to " & pitrcFilename
finally: close(cfgFile)
var cfgJson: JsonNode
try: cfgJson = parseFile(pitrcFilename)
except: raise newException(IOError,
"unable to read config file: " & pitrcFilename &
"\x0D\x0A" & getCurrentExceptionMsg())
let cfg = CombinedConfig(docopt: args, json: cfgJson)
result = CliContext( result = CliContext(
autoList: cfgJson.getOrDefault("autoList").getBool(false), cfg: pitCfg,
contexts: newTable[string,string](), contexts: pitCfg.contexts,
defaultContext:
if not cliJson.hasKey("defaultContext"): none(string)
else: some(cliJson["defaultContext"].getStr()),
verbose: parseBool(cliCfg.getVal("verbose", "false")) and not args["--quiet"],
issues: newTable[IssueState, seq[Issue]](), issues: newTable[IssueState, seq[Issue]](),
tasksDir: cfg.getVal("tasks-dir", ""), tasksDir: pitCfg.tasksDir,
termWidth: parseInt(cfg.getVal("term-width", "80")), termWidth: parseInt(cliCfg.getVal("termWidth", "80")),
triggerPtk: cfgJson.getOrDefault("triggerPtk").getBool(false)) triggerPtk: cliJson.getOrDefault("triggerPtk").getBool(false))
if cfgJson.hasKey("contexts"):
for k, v in cfgJson["contexts"]:
result.contexts[k] = v.getStr()
if isNilOrWhitespace(result.tasksDir):
raise newException(Exception, "no tasks directory configured")
if not existsDir(result.tasksDir):
raise newException(Exception, "cannot find tasks dir: " & result.tasksDir)
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]
proc writeIssue(ctx: CliContext, issue: Issue, state: IssueState, proc formatIssue(ctx: CliContext, issue: Issue): string =
width: int, indent: string, topPadded: bool) = result = ($issue.id).withColor(fgBlack, true) & "\n"&
var showDetails = not issue.details.isNilOrWhitespace issue.summary.withColor(fgWhite) & "\n"
if showDetails and not topPadded: stdout.writeLine("")
# Wrap and write the summary.
var wrappedSummary = (" ".repeat(5) & issue.summary).wordWrap(width - 2).indent(2 + indent.len)
wrappedSummary = wrappedSummary[(6 + indent.len)..^1]
stdout.setForegroundColor(fgBlack, true)
stdout.write(indent & ($issue.id)[0..<6])
stdout.setForegroundColor(fgCyan, false)
stdout.write(wrappedSummary)
if issue.tags.len > 0: if issue.tags.len > 0:
stdout.setForegroundColor(fgGreen, false) result &= "tags: ".withColor(fgMagenta) &
let tagsStr = "(" & issue.tags.join(",") & ")" issue.tags.join(",").withColor(fgGreen, true) & "\n"
if (wrappedSummary.splitLines[^1].len + tagsStr.len + 1) < (width - 2):
stdout.writeLine(" " & tagsStr)
else:
stdout.writeLine("\n" & indent & " " & tagsStr)
else: stdout.writeLine("")
stdout.resetAttributes
if state == Pending and issue.hasProp("pending"): if issue.properties.len > 0:
result &= termColor(fgMagenta)
for k, v in issue.properties: result &= k & ": " & v & "\n"
result &= "--------".withColor(fgBlack, true) & "\n"
if not issue.details.isEmptyOrWhitespace:
result &= issue.details.strip.withColor(fgCyan) & "\n"
proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
verbose = false): string =
result = ""
var showDetails = not issue.details.isEmptyOrWhitespace and verbose
var prefixLen = 0
var summaryIndentLen = indent.len + 7
if issue.hasProp("delegated-to"): prefixLen += issue["delegated-to"].len + 2 # space for the ':' and ' '
# Wrap and write the summary.
var wrappedSummary = ("+".repeat(prefixLen) & issue.summary).wrapWords(width - summaryIndentLen).indent(summaryIndentLen)
wrappedSummary = wrappedSummary[(prefixLen + summaryIndentLen)..^1]
result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true) & " "
if issue.hasProp("delegated-to"):
result &= (issue["delegated-to"] & ": ").withColor(fgGreen)
result &= wrappedSummary.withColor(fgWhite)
if issue.tags.len > 0:
let tagsStr = "(" & issue.tags.join(", ") & ")"
if (result.splitLines[^1].len + tagsStr.len + 1) > (width - 2):
result &= "\n" & indent
result &= " " & tagsStr.withColor(fgGreen)
if issue.hasProp("pending"):
let startIdx = "Pending: ".len let startIdx = "Pending: ".len
var pendingText = issue["pending"].wordWrap(width - startIdx - 2) var pendingText = issue["pending"].wrapWords(width - startIdx - summaryIndentLen)
.indent(startIdx) .indent(startIdx)
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2) pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(summaryIndentLen)
stdout.writeLine(pendingText) result &= "\n" & pendingText.withColor(fgCyan)
if showDetails: stdout.writeLine(issue.details.indent(indent.len + 2)) if showDetails:
result &= "\n" & issue.details.strip.indent(indent.len + 2).withColor(fgCyan)
result &= termReset
proc writeSection(ctx: CliContext, issues: seq[Issue], state: IssueState, proc formatSectionIssueList(ctx: CliContext, issues: seq[Issue], width: int,
indent = "") = indent: string, verbose: bool): string =
result = ""
for i in issues:
var issueText = ctx.formatSectionIssue(i, width, indent, verbose)
result &= issueText & "\n"
proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
indent = "", verbose = false): string =
let innerWidth = ctx.termWidth - (indent.len * 2) let innerWidth = ctx.termWidth - (indent.len * 2)
stdout.setForegroundColor(fgBlue, true) result = termColor(fgBlue) &
stdout.writeLine(indent & ".".repeat(innerWidth)) (indent & ".".repeat(innerWidth)) & "\n" &
stdout.writeLine(state.displayName.center(ctx.termWidth)) state.displayName.center(ctx.termWidth) & "\n\n" &
stdout.writeLine("") termReset
stdout.resetAttributes
let issuesByContext = issues.groupBy("context") let issuesByContext = issues.groupBy("context")
var topPadded = true
if issues.len > 5 and issuesByContext.len > 1: if issues.len > 5 and issuesByContext.len > 1:
for context, ctxIssues in issuesByContext: for context, ctxIssues in issuesByContext:
topPadded = true
stdout.setForegroundColor(fgYellow, false)
stdout.writeLine(indent & ctx.getIssueContextDisplayName(context) & ":")
stdout.writeLine("")
stdout.resetAttributes
for i in ctxIssues: result &= termColor(fgYellow) &
ctx.writeIssue(i, state, innerWidth - 2, indent & " ", topPadded) indent & ctx.getIssueContextDisplayName(context) & ":" &
topPadded = not i.details.isNilOrWhitespace termReset & "\n\n"
if not topPadded: stdout.writeLine("") result &= ctx.formatSectionIssueList(ctxIssues, innerWidth - 2, indent & " ", verbose)
result &= "\n"
else: else: result &= ctx.formatSectionIssueList(issues, innerWidth, indent, verbose)
for i in issues:
ctx.writeIssue(i, state, innerWidth, indent, topPadded)
topPadded = not i.details.isNilOrWhitespace
stdout.writeLine("")
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)
@ -165,16 +172,18 @@ proc writeHeader(ctx: CliContext, header: string) =
stdout.writeLine('~'.repeat(ctx.termWidth)) stdout.writeLine('~'.repeat(ctx.termWidth))
stdout.resetAttributes stdout.resetAttributes
proc reorder(ctx: CliContext, state: IssueState) =
# load the issues to make sure the order file contains all issues in the state.
ctx.loadIssues(state)
discard os.execShellCmd(EDITOR & " '" & (ctx.tasksDir / $state / "order.txt") & "' </dev/tty >/dev/tty")
proc edit(issue: Issue) = proc edit(issue: Issue) =
# Write format comments (to help when editing) # Write format comments (to help when editing)
writeFile(issue.filepath, toStorageFormat(issue, true)) writeFile(issue.filepath, toStorageFormat(issue, true))
let editor = discard os.execShellCmd(EDITOR & " '" & issue.filepath & "' </dev/tty >/dev/tty")
if existsEnv("EDITOR"): getEnv("EDITOR")
else: "vi"
discard os.execShellCmd(editor & " " & issue.filepath & " </dev/tty >/dev/tty")
try: try:
# Try to parse the newly-edited issue to make sure it was successful. # Try to parse the newly-edited issue to make sure it was successful.
@ -185,17 +194,23 @@ proc edit(issue: Issue) =
getCurrentExceptionMsg() getCurrentExceptionMsg()
issue.store() issue.store()
proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState], today, future: bool) = proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState], showToday, showFuture, verbose: bool) =
if state.isSome: if state.isSome:
ctx.loadIssues(state.get) ctx.loadIssues(state.get)
if filter.isSome: ctx.filterIssues(filter.get) if filter.isSome: ctx.filterIssues(filter.get)
ctx.writeSection(ctx.issues[state.get], state.get) stdout.write ctx.formatSection(ctx.issues[state.get], state.get, "", verbose)
return return
ctx.loadAllIssues() ctx.loadAllIssues()
if filter.isSome: ctx.filterIssues(filter.get) if filter.isSome: ctx.filterIssues(filter.get)
let today = showToday and [Current, TodoToday].anyIt(
ctx.issues.hasKey(it) and ctx.issues[it].len > 0)
let future = showFuture and [Pending, Todo].anyIt(
ctx.issues.hasKey(it) and ctx.issues[it].len > 0)
let indent = if today and future: " " else: "" let indent = if today and future: " " else: ""
# Today's items # Today's items
@ -204,14 +219,14 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState
for s in [Current, TodoToday]: for s in [Current, TodoToday]:
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0: if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
ctx.writeSection(ctx.issues[s], s, indent) stdout.write ctx.formatSection(ctx.issues[s], s, indent, verbose)
if ctx.issues.hasKey(Done): if ctx.issues.hasKey(Done):
let doneIssues = ctx.issues[Done].filterIt( let doneIssues = ctx.issues[Done].filterIt(
it.hasProp("completed") and it.hasProp("completed") and
sameDay(getTime().local, it.getDateTime("completed"))) sameDay(getTime().local, it.getDateTime("completed")))
if doneIssues.len > 0: if doneIssues.len > 0:
ctx.writeSection(doneIssues, Done, indent) stdout.write ctx.formatSection(doneIssues, Done, indent, verbose)
# Future items # Future items
if future: if future:
@ -219,7 +234,7 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState
for s in [Pending, Todo]: for s in [Pending, Todo]:
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0: if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
ctx.writeSection(ctx.issues[s], s, indent) stdout.write ctx.formatSection(ctx.issues[s], s, indent, verbose)
when isMainModule: when isMainModule:
@ -227,42 +242,61 @@ when isMainModule:
let doc = """ let doc = """
Usage: Usage:
pit ( new | add) <summary> [<state>] [options] pit ( new | add) <summary> [<state>] [options]
pit list [<state>] [options] pit list contexts
pit ( start | done | pending | do-today | todo | suspend ) <id>... pit list [<stateOrId>] [options]
pit edit <id> pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options]
pit delete <id>... pit edit <ref>...
pit tag <id>... [options]
pit untag <id>... [options]
pit reorder <state>
pit delegate <id> <delegated-to>
pit ( delete | rm ) <id>...
Options: Options:
-h, --help Print this usage information. -h, --help Print this usage information.
-t, --tags <tags> Specify tags for an issue.
-p, --properties <props> Specify properties. Formatted as "key:val;key:val" -p, --properties <props> Specify properties. Formatted as "key:val;key:val"
When used with the list command this option applies When used with the list command this option applies
a filter to the issues listed, only allowing those a filter to the issues listed, only allowing those
which have all of the given properties. 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. -T, --today Limit to today's issues.
-F, --future Limit to future 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. -y, --yes Automatically answer "yes" to any prompts.
-C, --config <cfgFile> Location of the config file (defaults to $HOME/.pitrc) -C, --config <cfgFile> Location of the config file (defaults to $HOME/.pitrc)
-E, --echo-args Echo arguments (for debug purposes). -E, --echo-args Echo arguments (for debug purposes).
--tasks-dir Path to the tasks directory (defaults to the value -d, --tasks-dir Path to the tasks directory (defaults to the value
configured in the .pitrc file) configured in the .pitrc file)
--term-width Manually set the terminal width to use. --term-width <width> Manually set the terminal width to use.
--ptk Enable PTK integration for this command.
""" """
logging.addHandler(newConsoleLogger()) logging.addHandler(newConsoleLogger())
# Parse arguments # Parse arguments
let args = docopt(doc, version = "pit 4.0.4") let args = docopt(doc, version = PIT_VERSION)
if args["--echo-args"]: stderr.writeLine($args) if args["--echo-args"]: stderr.writeLine($args)
@ -272,10 +306,20 @@ Options:
let ctx = initContext(args) let ctx = initContext(args)
# Create our tasks directory structure if needed var propertiesOption = none(TableRef[string,string])
for s in IssueState: var tagsOption = none(seq[string])
if not existsDir(ctx.tasksDir / $s):
(ctx.tasksDir / $s).createDir if args["--properties"] or args["--context"]:
var props =
if args["--properties"]: parsePropertiesOption($args["--properties"])
else: newTable[string,string]()
if args["--context"]: props["context"] = $args["--context"]
propertiesOption = some(props)
if args["--tags"]: tagsOption = some(($args["--tags"]).split(",").mapIt(it.strip))
## Actual command runners ## Actual command runners
if args["new"] or args["add"]: if args["new"] or args["add"]:
@ -283,47 +327,109 @@ Options:
if args["<state>"]: parseEnum[IssueState]($args["<state>"]) if args["<state>"]: parseEnum[IssueState]($args["<state>"])
else: TodoToday else: TodoToday
var issueProps = propertiesOption.get(newTable[string,string]())
if not issueProps.hasKey("created"): issueProps["created"] = getTime().local.formatIso8601
if not issueProps.hasKey("context") and ctx.defaultContext.isSome():
stderr.writeLine("Using default context: " & ctx.defaultContext.get)
issueProps["context"] = ctx.defaultContext.get
var issue = Issue( var issue = Issue(
id: genUUID(), id: genUUID(),
summary: $args["<summary>"], summary: $args["<summary>"],
properties: properties: issueProps,
if args["--properties"]: parsePropertiesOption($args["--properties"])
else: newTable[string,string](),
tags: tags:
if args["--tags"]: ($args["tags"]).split(",").mapIt(it.strip) if args["--tags"]: ($args["--tags"]).split(",").mapIt(it.strip)
else: newSeq[string]()) else: newSeq[string]())
ctx.tasksDir.store(issue, state) ctx.tasksDir.store(issue, state)
stdout.writeLine ctx.formatIssue(issue)
elif args["reorder"]:
ctx.reorder(parseEnum[IssueState]($args["<state>"]))
elif args["edit"]: elif args["edit"]:
let issueId = $args["<id>"] for editRef in @(args["<ref>"]):
edit(ctx.tasksDir.loadIssueById(issueId)) var stateOption = none(IssueState)
elif args["start"] or args["do-today"] or args["done"] or try: stateOption = some(parseEnum[IssueState](editRef))
except: discard
if stateOption.isSome:
let state = stateOption.get
ctx.loadIssues(state)
for issue in ctx.issues[state]: edit(issue)
else: edit(ctx.tasksDir.loadIssueById(editRef))
elif args["tag"]:
if not args["--tags"]: raise newException(Exception, "no tags given")
let newTags = ($args["--tags"]).split(",").mapIt(it.strip)
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 args["--tags"]: ($args["--tags"]).split(",").mapIt(it.strip)
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
args["pending"] or args["todo"] or args["suspend"]: args["pending"] or args["todo"] or args["suspend"]:
var targetState: IssueState var targetState: IssueState
if args["done"]: targetState = Done if args["done"]: targetState = Done
elif args["do-today"]: targetState = TodoToday elif args["todo-today"]: targetState = TodoToday
elif args["pending"]: targetState = Todo elif args["pending"]: targetState = Pending
elif args["start"]: targetState = Current elif args["start"]: targetState = Current
elif args["todo"]: targetState = Todo elif args["todo"]: targetState = Todo
elif args["suspend"]: targetState = Dormant elif args["suspend"]: targetState = Dormant
for id in @(args["<id>"]): for id in @(args["<id>"]):
ctx.tasksDir.loadIssueById(id).changeState(ctx.tasksDir, targetState) var issue = ctx.tasksDir.loadIssueById(id)
if propertiesOption.isSome:
for k,v in propertiesOption.get:
issue[k] = v
if targetState == Done: issue["completed"] = getTime().local.formatIso8601
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.properties.hasKey("context"):
let tags = concat(
issue.tags,
if issue.properties.hasKey("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: discard execShellCmd("ptk stop") elif targetState == Done or targetState == Pending:
discard execShellCmd("ptk stop")
elif args["delete"]: elif args["delegate"]:
let issue = ctx.tasksDir.loadIssueById($(args["<id>"]))
issue["delegated-to"] = $args["<delegated-to>"]
issue.store()
elif args["delete"] or args["rm"]:
for id in @(args["<id>"]): for id in @(args["<id>"]):
let issue = ctx.tasksDir.loadIssueById(id) let issue = ctx.tasksDir.loadIssueById(id)
@ -339,21 +445,70 @@ Options:
let filter = initFilter() let filter = initFilter()
var filterOption = none(IssueFilter) var filterOption = none(IssueFilter)
if args["--properties"]:
filter.properties = parsePropertiesOption($args["--properties"]) # Initialize filter with properties (if given)
if propertiesOption.isSome:
filter.properties = propertiesOption.get
filterOption = some(filter) filterOption = some(filter)
let stateOption = # If they supplied text matches, add that to the filter.
if args["<state>"]: some(parseEnum[IssueState]($args["<state>"])) if args["--match"]:
else: none(IssueState) filter.summaryMatch = some(re("(?i)" & $args["--match"]))
filterOption = some(filter)
let showBoth = args["--today"] == args["--future"] if args["--match-all"]:
ctx.list(filterOption, stateOption, showBoth or args["--today"], filter.fullMatch = some(re("(?i)" & $args["--match-all"]))
showBoth or args["--future"]) filterOption = some(filter)
if ctx.autoList and not args["list"]: # If no "context" property is given, use the default (if we have one)
ctx.loadAllIssues() if ctx.defaultContext.isSome and not filter.properties.hasKey("context"):
ctx.list(none(IssueFilter), none(IssueState), true, true) stderr.writeLine("Limiting to default context: " & ctx.defaultContext.get)
filter.properties["context"] = ctx.defaultContext.get
filterOption = some(filter)
# Finally, if the "context" is "all", don't filter on context
if filter.properties.hasKey("context") and
filter.properties["context"] == "all":
filter.properties.del("context")
var listContexts = false
var stateOption = none(IssueState)
var issueIdOption = none(string)
if args["contexts"]: listContexts = true
elif args["<stateOrId>"]:
try: stateOption = some(parseEnum[IssueState]($args["<stateOrId>"]))
except: issueIdOption = some($args["<stateOrId>"])
# List the known contexts
if listContexts:
var uniqContexts = toSeq(ctx.contexts.keys)
ctx.loadAllIssues()
for state, issueList in ctx.issues:
for issue in issueList:
if issue.hasProp("context") and not uniqContexts.contains(issue["context"]):
uniqContexts.add(issue["context"])
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
elif issueIdOption.isSome:
let issue = ctx.tasksDir.loadIssueById(issueIdOption.get)
stdout.writeLine ctx.formatIssue(issue)
# List all issues
else:
let showBoth = args["--today"] == args["--future"]
ctx.list(filterOption, stateOption, showBoth or args["--today"],
showBoth or args["--future"],
ctx.verbose)
except: except:
fatal "pit: " & getCurrentExceptionMsg() fatal "pit: " & getCurrentExceptionMsg()

135
src/pit_api.nim Normal file
View File

@ -0,0 +1,135 @@
## Personal Issue Tracker API Interface
## ====================================
import asyncdispatch, cliutils, docopt, jester, json, logging, options, sequtils, strutils
import nre except toSeq
import pitpkg/private/libpit
include "pitpkg/version.nim"
type
PitApiCfg* = object
apiKeys*: seq[string]
global*: PitConfig
port*: int
const TXT = "text/plain"
proc raiseEx(reason: string): void = raise newException(Exception, reason)
template halt(code: HttpCode,
headers: RawHeaders,
content: string): typed =
## 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) =
## Check this request for authentication and authorization information.
## If the request is not authorized, this template sets up the 401 response
## correctly. The calling context needs only to return from the route.
var authed {.inject.} = false
try:
if not request.headers.hasKey("Authorization"):
raiseEx "No auth token."
let headerVal = request.headers["Authorization"]
if not headerVal.startsWith("Bearer "):
raiseEx "Invalid Authentication type (only 'Bearer' is supported)."
if not cfg.apiKeys.contains(headerVal[7..^1]):
raiseEx "Invalid API key."
authed = true
except:
stderr.writeLine "Auth failed: " & getCurrentExceptionMsg()
halt(
Http401,
@{"Content-Type": TXT},
getCurrentExceptionMsg())
proc start*(cfg: PitApiCfg) =
var stopFuture = newFuture[void]()
settings:
port = Port(cfg.port)
appName = "/api"
routes:
get "/ping":
resp("pong", TXT)
get "/issues":
checkAuth(cfg)
var args = queryParamsToCliArgs(request.params)
args = @["list"] & args
info "args: \n" & args.join(" ")
let execResult = execWithOutput("pit", ".", args)
if execResult[2] != 0: resp(Http500, stripAnsi($execResult[0] & "\n" & $execResult[1]), TXT)
else: resp(stripAnsi(execResult[0]), TXT)
post "/issues":
checkAuth(cfg)
get "/issue/@issueId":
checkAuth(cfg)
var args = queryParamsToCliArgs(request.params)
args = @["list", @"issueId"] & args
info "args: \n" & args.join(" ")
let execResult = execWithOutput("pit", ".", args)
if execResult[2] != 0: resp(Http500, stripAnsi($execResult[0] & "\n" & $execResult[1]), TXT)
else: resp(stripAnsi(execResult[0]), TXT)
waitFor(stopFuture)
proc loadApiConfig(args: Table[string, Value]): PitApiCfg =
let pitCfg = loadConfig(args)
let apiJson =
if pitCfg.cfg.json.hasKey("api"): pitCfg.cfg.json["api"]
else: newJObject()
let apiCfg = CombinedConfig(docopt: args, json: apiJson)
let apiKeysArray =
if apiJson.hasKey("apiKeys"): apiJson["apiKeys"]
else: newJArray()
result = PitApiCfg(
apiKeys: toSeq(apiKeysArray).mapIt(it.getStr),
global: pitCfg,
port: parseInt(apiCfg.getVal("port", "8123")))
when isMainModule:
let doc = """\
Usage:
pit_api [options]
Options:
-c, --config <cfgFile> Path to the pit_api config file.
-d, --tasks-dir Path to the tasks directory.
-p, --port Port to listen on (defaults to 8123)
"""
let args = docopt(doc, version = PIT_VERSION)
let apiCfg = loadApiConfig(args)
start(apiCfg)

View File

@ -1,6 +1,8 @@
import cliutils, options, os, ospaths, sequtils, strutils, tables, times, timeutils, uuids import cliutils, docopt, json, logging, langutils, options, os,
sequtils, strutils, tables, times, timeutils, uuids
from nre import find, match, re, Regex
from nre import re, match
type type
Issue* = ref object Issue* = ref object
id*: UUID id*: UUID
@ -18,8 +20,14 @@ type
Dormant = "dormant" Dormant = "dormant"
IssueFilter* = ref object IssueFilter* = ref object
completedRange*: Option[tuple[b, e: DateTime]]
fullMatch*, summaryMatch*: Option[Regex]
properties*: TableRef[string, string] properties*: TableRef[string, string]
completedRange*: tuple[b, e: DateTime]
PitConfig* = ref object
tasksDir*: string
contexts*: TableRef[string, string]
cfg*: CombinedConfig
const DONE_FOLDER_FORMAT* = "yyyy-MM" const DONE_FOLDER_FORMAT* = "yyyy-MM"
@ -41,6 +49,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)
@ -54,24 +63,41 @@ 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
## Issue filtering
proc initFilter*(): IssueFilter = proc initFilter*(): IssueFilter =
result = IssueFilter( result = IssueFilter(
properties: newTable[string,string](), completedRange: none(tuple[b, e: DateTime]),
completedRange: (fromUnix(0).local, fromUnix(253400659199).local)) fullMatch: none(Regex),
summaryMatch: none(Regex),
properties: newTable[string, string]())
proc initFilter*(props: TableRef[string, string]): IssueFilter = proc propsFilter*(props: TableRef[string, string]): IssueFilter =
if isNil(props): if isNil(props):
raise newException(ValueError, raise newException(ValueError,
"cannot initialize property filter without properties") "cannot initialize property filter without properties")
result = IssueFilter( result = initFilter()
properties: props, result.properties = props
completedRange: (fromUnix(0).local, fromUnix(253400659199).local))
proc dateFilter*(range: tuple[b, e: DateTime]): IssueFilter =
result = initFilter()
result.completedRange = some(range)
proc summaryMatchFilter*(pattern: string): IssueFilter =
result = initFilter()
result.summaryMatch = some(re("(?i)" & pattern))
proc fullMatchFilter*(pattern: string): IssueFilter =
result = initFilter()
result.fullMatch = some(re("(?i)" & pattern))
proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] =
result = newTable[string, seq[Issue]]()
for i in issues:
let key = if i.hasProp(propertyKey): i[propertyKey] else: ""
if not result.hasKey(key): result[key] = newSeq[Issue]()
result[key].add(i)
proc initFilter*(range: tuple[b, e: DateTime]): IssueFilter =
result = IssueFilter(
properties: newTable[string, string](),
completedRange: range)
## Parse and format issues ## Parse and format issues
proc fromStorageFormat*(id: string, issueTxt: string): Issue = proc fromStorageFormat*(id: string, issueTxt: string): Issue =
@ -96,7 +122,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 == "--------":
@ -122,9 +148,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)
@ -143,6 +170,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) =
@ -154,38 +182,127 @@ 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) =
var orderLines = newSeq[string]()
for context, issues in issues.groupBy("context"):
orderLines.add("> " & context)
for issue in issues: orderLines.add($issue.id & " " & issue.summary)
orderLines.add("")
let orderFile = path / "order.txt"
orderFile.writeFile(orderLines.join("\n"))
proc loadIssues*(path: string): seq[Issue] = proc loadIssues*(path: string): seq[Issue] =
result = @[] let orderFile = path / "order.txt"
let orderedIds =
if fileExists(orderFile):
toSeq(orderFile.lines)
.mapIt(it.split(' ')[0])
.deduplicate
.filterIt(not it.startsWith("> ") and not it.isEmptyOrWhitespace)
else: newSeq[string]()
type TaggedIssue = tuple[issue: Issue, ordered: bool]
var unorderedIssues: seq[TaggedIssue] = @[]
for path in walkDirRec(path): for path in walkDirRec(path):
if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome(): if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome():
result.add(loadIssue(path)) unorderedIssues.add((loadIssue(path), false))
result = @[]
# Add all ordered issues in order
for id in orderedIds:
let idx = unorderedIssues.indexOf(($it.issue.id).startsWith(id))
if idx > 0:
result.add(unorderedIssues[idx].issue)
unorderedIssues[idx].ordered = true
# Add all remaining, unordered issues in the order they were loaded
for taggedIssue in unorderedIssues:
if taggedIssue.ordered: continue
result.add(taggedIssue.issue)
# Finally, save current order
result.storeOrder(path)
proc changeState*(issue: Issue, tasksDir: string, newState: IssueState) = proc changeState*(issue: Issue, tasksDir: string, newState: IssueState) =
let oldFilepath = issue.filepath let oldFilepath = issue.filepath
if newState == Done: issue.setDateTime("completed", getTime().local) if newState == Done: issue.setDateTime("completed", getTime().local)
tasksDir.store(issue, newState) tasksDir.store(issue, newState)
removeFile(oldFilepath) if oldFilePath != issue.filepath: removeFile(oldFilepath)
proc delete*(issue: Issue) = removeFile(issue.filepath) proc delete*(issue: Issue) = removeFile(issue.filepath)
## Utilities for working with issue collections. ## Utilities for working with issue collections.
proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] =
result = newTable[string, seq[Issue]]()
for i in issues:
let key = if i.hasProp(propertyKey): i[propertyKey] else: ""
if not result.hasKey(key): result[key] = newSeq[Issue]()
result[key].add(i)
proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] = proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
result = issues result = issues
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)
result = result.filterIt(not it.hasProp("completed") or if filter.completedRange.isSome:
it.getDateTime("completed").between( let range = filter.completedRange.get
filter.completedRange.b, result = result.filterIt(
filter.completedRange.e)) not it.hasProp("completed") or
it.getDateTime("completed").between(range.b, range.e))
if filter.summaryMatch.isSome:
let p = filter.summaryMatch.get
result = result.filterIt(it.summary.find(p).isSome)
if filter.fullMatch.isSome:
let p = filter.fullMatch.get
result = result.filterIt( it.summary.find(p).isSome or it.details.find(p).isSome)
### Configuration utilities
proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitConfig =
let pitrcLocations = @[
if args["--config"]: $args["--config"] else: "",
".pitrc", $getEnv("PITRC"), $getEnv("HOME") & "/.pitrc"]
var pitrcFilename: string =
foldl(pitrcLocations, if len(a) > 0: a elif fileExists(b): b else: "")
if not fileExists(pitrcFilename):
warn "pit: could not find .pitrc file: " & pitrcFilename
if isEmptyOrWhitespace(pitrcFilename):
pitrcFilename = $getEnv("HOME") & "/.pitrc"
var cfgFile: File
try:
cfgFile = open(pitrcFilename, fmWrite)
cfgFile.write("{\"tasksDir\": \"/path/to/tasks\"}")
except: warn "pit: could not write default .pitrc to " & pitrcFilename
finally: close(cfgFile)
var cfgJson: JsonNode
try: cfgJson = parseFile(pitrcFilename)
except: raise newException(IOError,
"unable to read config file: " & pitrcFilename &
"\x0D\x0A" & getCurrentExceptionMsg())
let cfg = CombinedConfig(docopt: args, json: cfgJson)
result = PitConfig(
cfg: cfg,
contexts: newTable[string,string](),
tasksDir: cfg.getVal("tasks-dir", ""))
if cfgJson.hasKey("contexts"):
for k, v in cfgJson["contexts"]:
result.contexts[k] = v.getStr()
if isEmptyOrWhitespace(result.tasksDir):
raise newException(Exception, "no tasks directory configured")
if not dirExists(result.tasksDir):
raise newException(Exception, "cannot find tasks dir: " & result.tasksDir)
# Create our tasks directory structure if needed
for s in IssueState:
if not dirExists(result.tasksDir / $s):
(result.tasksDir / $s).createDir

1
src/pitpkg/version.nim Normal file
View File

@ -0,0 +1 @@
const PIT_VERSION* = "4.9.4"