Compare commits

...

12 Commits

6 changed files with 205 additions and 69 deletions

View File

@ -136,6 +136,3 @@ in the configuration file. All options are optional unless stated otherwise.
* `tasksDir` **required**: a file path to the root directory for the issue
repository (same as `--tasks-dir` CLI parameter).
- CLI parameter: *cannot be specified via CLI*
- config file key: `contexts`

View File

@ -1,6 +1,6 @@
# Package
version = "4.9.0"
version = "4.14.0"
author = "Jonathan Bernard"
description = "Personal issue tracker."
license = "MIT"
@ -10,15 +10,16 @@ bin = @["pit", "pit_api"]
# Dependencies
requires @[
"nim >= 0.19.0",
"nim >= 1.4.0",
"docopt 0.6.8",
"jester 0.4.1",
"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"
"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"
]
task updateVersion, "Update the version of this package.":

View File

@ -1,8 +1,8 @@
## Personal Issue Tracker CLI interface
## ====================================
import cliutils, docopt, json, logging, options, os, sequtils,
tables, terminal, times, timeutils, unicode, uuids
import algorithm, cliutils, data_uri, docopt, json, logging, options, os,
sequtils, std/wordwrap, tables, terminal, times, timeutils, unicode, uuids
from nre import re
import strutils except alignLeft, capitalize, strip, toUpper, toLower
@ -49,7 +49,7 @@ proc initContext(args: Table[string, Value]): CliContext =
proc getIssueContextDisplayName(ctx: CliContext, context: string): string =
if not ctx.contexts.hasKey(context):
if context.isNilOrWhitespace: return "<default>"
if context.isEmptyOrWhitespace: return "<default>"
else: return context.capitalize()
return ctx.contexts[context]
@ -67,15 +67,17 @@ proc formatIssue(ctx: CliContext, issue: Issue): string =
result &= "--------".withColor(fgBlack, true) & "\n"
if not issue.details.isNilOrWhitespace:
if not issue.details.isEmptyOrWhitespace:
result &= issue.details.strip.withColor(fgCyan) & "\n"
result &= termReset
proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
verbose = false): string =
result = ""
var showDetails = not issue.details.isNilOrWhitespace and verbose
var showDetails = not issue.details.isEmptyOrWhitespace and verbose
var prefixLen = 0
var summaryIndentLen = indent.len + 7
@ -83,7 +85,7 @@ proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
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).wordWrap(width - summaryIndentLen).indent(summaryIndentLen)
var wrappedSummary = ("+".repeat(prefixLen) & issue.summary).wrapWords(width - summaryIndentLen).indent(summaryIndentLen)
wrappedSummary = wrappedSummary[(prefixLen + summaryIndentLen)..^1]
@ -103,7 +105,7 @@ proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
if issue.hasProp("pending"):
let startIdx = "Pending: ".len
var pendingText = issue["pending"].wordWrap(width - startIdx - summaryIndentLen)
var pendingText = issue["pending"].wrapWords(width - startIdx - summaryIndentLen)
.indent(startIdx)
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(summaryIndentLen)
result &= "\n" & pendingText.withColor(fgCyan)
@ -194,18 +196,23 @@ proc edit(issue: Issue) =
getCurrentExceptionMsg()
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, verbose: bool) =
if state.isSome:
ctx.loadIssues(state.get)
if states.isSome:
for state in states.get:
ctx.loadIssues(state)
if filter.isSome: ctx.filterIssues(filter.get)
stdout.write ctx.formatSection(ctx.issues[state.get], state.get, "", verbose)
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)
return
ctx.loadAllIssues()
if filter.isSome: ctx.filterIssues(filter.get)
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)
let future = showFuture and [Pending, Todo].anyIt(
@ -217,43 +224,44 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState
if 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:
stdout.write ctx.formatSection(ctx.issues[s], s, indent, verbose)
if ctx.issues.hasKey(Done):
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
if future:
if today: ctx.writeHeader("Future")
for s in [Pending, Todo]:
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(
not (it.hasProp("hide-until") and
it.getDateTime("hide-until") > getTime().local))
stdout.write ctx.formatSection(visibleIssues, s, indent, verbose)
when isMainModule:
try:
let doc = """
let usage = """
Usage:
pit ( new | add) <summary> [<state>] [options]
pit list [<listable>] [options]
pit list contexts [options]
pit list [<stateOrId>...] [options]
pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options]
pit edit <ref>...
pit edit <ref>... [options]
pit tag <id>... [options]
pit untag <id>... [options]
pit reorder <state>
pit reorder <state> [options]
pit delegate <id> <delegated-to>
pit ( delete | rm ) <id>...
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]
Options:
-h, --help Print this usage information.
-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
@ -290,17 +298,84 @@ Options:
--term-width <width> Manually set the terminal width to use.
--ptk Enable PTK integration for this command.
"""
let onlineHelp = """
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
TODO, not yet implemented.
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>'.
"""
logging.addHandler(newConsoleLogger())
# Parse arguments
let args = docopt(doc, version = PIT_VERSION)
let args = docopt(usage, version = PIT_VERSION)
if args["--echo-args"]: stderr.writeLine($args)
if args["--help"]:
stderr.writeLine(doc)
stderr.writeLine(usage)
stderr.writeLine(onlineHelp)
quit()
let ctx = initContext(args)
@ -421,6 +496,13 @@ Options:
elif targetState == Done or targetState == Pending:
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>"]))
@ -465,6 +547,10 @@ Options:
filter.properties["context"] = ctx.defaultContext.get
filterOption = some(filter)
if args["--tags"]:
filter.hasTags = ($args["--tags"]).split(',')
filterOption = some(filter)
# Finally, if the "context" is "all", don't filter on context
if filter.properties.hasKey("context") and
filter.properties["context"] == "all":
@ -472,14 +558,13 @@ Options:
filter.properties.del("context")
var listContexts = false
var stateOption = none(IssueState)
var issueIdOption = none(string)
var statesOption = none(seq[IssueState])
var issueIdsOption = none(seq[string])
if args["<listable>"]:
if $args["<listable>"] == "contexts": listContexts = true
else:
try: stateOption = some(parseEnum[IssueState]($args["<listable>"]))
except: issueIdOption = some($args["<listable>"])
if args["contexts"]: listContexts = true
elif args["<stateOrId>"]:
try: statesOption = some(args["<stateOrId>"].mapIt(parseEnum[IssueState]($it)))
except: issueIdsOption = some(args["<stateOrId>"].mapIt($it))
# List the known contexts
if listContexts:
@ -495,21 +580,49 @@ Options:
else: b
).len
for c in uniqContexts:
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)
elif issueIdsOption.isSome:
for issueId in issueIdsOption.get:
let issue = ctx.tasksDir.loadIssueById(issueId)
stdout.writeLine ctx.formatIssue(issue)
# List all issues
else:
let showBoth = args["--today"] == args["--future"]
ctx.list(filterOption, stateOption, showBoth or args["--today"],
ctx.list(filterOption, statesOption, showBoth or args["--today"],
showBoth or args["--future"],
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:
fatal "pit: " & getCurrentExceptionMsg()
#raise getCurrentException()

View File

@ -20,7 +20,7 @@ proc raiseEx(reason: string): void = raise newException(Exception, reason)
template halt(code: HttpCode,
headers: RawHeaders,
content: string): typed =
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.

View File

@ -22,6 +22,7 @@ type
IssueFilter* = ref object
completedRange*: Option[tuple[b, e: DateTime]]
fullMatch*, summaryMatch*: Option[Regex]
hasTags*: seq[string]
properties*: TableRef[string, string]
PitConfig* = ref object
@ -69,6 +70,7 @@ proc initFilter*(): IssueFilter =
completedRange: none(tuple[b, e: DateTime]),
fullMatch: none(Regex),
summaryMatch: none(Regex),
hasTags: @[],
properties: newTable[string, string]())
proc propsFilter*(props: TableRef[string, string]): IssueFilter =
@ -91,6 +93,10 @@ proc fullMatchFilter*(pattern: string): IssueFilter =
result = initFilter()
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]] =
result = newTable[string, seq[Issue]]()
for i in issues:
@ -99,6 +105,23 @@ proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Iss
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
proc fromStorageFormat*(id: string, issueTxt: string): Issue =
type ParseState = enum ReadingSummary, ReadingProps, ReadingDetails
@ -122,7 +145,7 @@ proc fromStorageFormat*(id: string, issueTxt: string): Issue =
of ReadingProps:
# Ignore empty lines
if line.isNilOrWhitespace: continue
if line.isEmptyOrWhitespace: continue
# Look for the sentinal to start parsing as detail lines
if line == "--------":
@ -149,9 +172,9 @@ proc toStorageFormat*(issue: Issue, withComments = false): string =
lines.add(issue.summary)
if withComments: lines.add("# Properties (\"key:value\" per line):")
for key, val in issue.properties:
if not val.isNilOrWhitespace: lines.add(key & ": " & val)
if not val.isEmptyOrWhitespace: lines.add(key & ": " & val)
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 \"--------\"")
lines.add("--------")
lines.add(issue.details)
@ -170,6 +193,7 @@ proc loadIssueById*(tasksDir, id: string): Issue =
raise newException(KeyError, "cannot find issue for id: " & id)
proc store*(issue: Issue, withComments = false) =
discard existsOrCreateDir(issue.filePath.parentDir)
writeFile(issue.filepath, toStorageFormat(issue, withComments))
proc store*(tasksDir: string, issue: Issue, state: IssueState, withComments = false) =
@ -202,7 +226,7 @@ proc loadIssues*(path: string): seq[Issue] =
toSeq(orderFile.lines)
.mapIt(it.split(' ')[0])
.deduplicate
.filterIt(not it.startsWith("> ") and not it.isNilOrWhitespace)
.filterIt(not it.startsWith("> ") and not it.isEmptyOrWhitespace)
else: newSeq[string]()
type TaggedIssue = tuple[issue: Issue, ordered: bool]
@ -258,6 +282,9 @@ proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
let p = filter.fullMatch.get
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)
### Configuration utilities
proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitConfig =
let pitrcLocations = @[
@ -265,11 +292,11 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
".pitrc", $getEnv("PITRC"), $getEnv("HOME") & "/.pitrc"]
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
if isNilOrWhitespace(pitrcFilename):
if isEmptyOrWhitespace(pitrcFilename):
pitrcFilename = $getEnv("HOME") & "/.pitrc"
var cfgFile: File
try:
@ -295,15 +322,13 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
for k, v in cfgJson["contexts"]:
result.contexts[k] = v.getStr()
if isNilOrWhitespace(result.tasksDir):
if isEmptyOrWhitespace(result.tasksDir):
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)
# Create our tasks directory structure if needed
for s in IssueState:
if not existsDir(result.tasksDir / $s):
if not dirExists(result.tasksDir / $s):
(result.tasksDir / $s).createDir

View File

@ -1 +1 @@
const PIT_VERSION* = "4.9.0"
const PIT_VERSION* = "4.15.0"