355 lines
11 KiB
Nim
Raw Normal View History

2018-05-11 18:39:40 -05:00
## Personal Issue Tracker
## ======================
##
2018-05-11 18:39:40 -05:00
import cliutils, docopt, json, logging, options, os, ospaths, sequtils,
tables, terminal, times, unicode, uuids
2018-05-11 18:39:40 -05:00
import strutils except capitalize, toUpper, toLower
import pitpkg/private/libpit
2018-05-11 18:39:40 -05:00
export libpit
type
CliContext = ref object
autoList, triggerPtk: bool
2018-05-11 18:39:40 -05:00
tasksDir*: string
contexts*: TableRef[string, string]
issues*: TableRef[IssueState, seq[Issue]]
2018-05-12 11:40:15 -05:00
termWidth*: int
2018-05-11 18:39:40 -05:00
proc initContext(args: Table[string, Value]): CliContext =
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 existsFile(b): b else: "")
if not existsFile(pitrcFilename):
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(
autoList: cfgJson.getOrDefault("autoList").getBool(false),
2018-05-11 18:39:40 -05:00
contexts: newTable[string,string](),
2018-05-12 11:40:15 -05:00
issues: newTable[IssueState, seq[Issue]](),
tasksDir: cfg.getVal("tasks-dir", ""),
termWidth: parseInt(cfg.getVal("term-width", "80")),
triggerPtk: cfgJson.getOrDefault("triggerPtk").getBool(false))
2018-05-11 18:39:40 -05:00
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 =
if not ctx.contexts.hasKey(context):
if context.isNilOrWhitespace: return "<default>"
else: return context.capitalize()
2018-05-11 18:39:40 -05:00
return ctx.contexts[context]
proc writeIssue(ctx: CliContext, issue: Issue, state: IssueState,
width: int, indent: string, topPadded: bool) =
2018-05-11 18:39:40 -05:00
var showDetails = not issue.details.isNilOrWhitespace
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:
stdout.setForegroundColor(fgGreen, false)
let tagsStr = "(" & issue.tags.join(",") & ")"
if (wrappedSummary.splitLines[^1].len + tagsStr.len + 1) < (width - 2):
stdout.writeLine(" " & tagsStr)
else:
stdout.writeLine("\n" & indent & " " & tagsStr)
else: stdout.writeLine("")
stdout.resetAttributes
2018-05-11 18:39:40 -05:00
if state == Pending and issue.hasProp("pending"):
2018-05-11 18:39:40 -05:00
let startIdx = "Pending: ".len
2018-05-12 11:40:15 -05:00
var pendingText = issue["pending"].wordWrap(width - startIdx - 2)
.indent(startIdx)
2018-05-11 18:39:40 -05:00
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2)
stdout.writeLine(pendingText)
2018-05-11 18:39:40 -05:00
if showDetails: stdout.writeLine(issue.details.indent(indent.len + 2))
2018-05-11 18:39:40 -05:00
proc writeSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
indent = "") =
2018-05-12 11:40:15 -05:00
let innerWidth = ctx.termWidth - (indent.len * 2)
2018-05-11 18:39:40 -05:00
stdout.setForegroundColor(fgBlue, true)
stdout.writeLine(indent & ".".repeat(innerWidth))
stdout.writeLine(state.displayName.center(ctx.termWidth))
stdout.writeLine("")
stdout.resetAttributes
2018-05-11 18:39:40 -05:00
var topPadded = true
let issuesByContext = issues.groupBy("context")
if issues.len > 5 and issuesByContext.len > 1:
for context, ctxIssues in issuesByContext:
stdout.setForegroundColor(fgYellow, false)
stdout.writeLine(indent & ctx.getIssueContextDisplayName(context) & ":")
stdout.writeLine("")
stdout.resetAttributes
2018-05-11 18:39:40 -05:00
for i in ctxIssues:
ctx.writeIssue(i, state, innerWidth - 2, indent & " ", topPadded)
2018-05-11 18:39:40 -05:00
topPadded = not i.details.isNilOrWhitespace
if not topPadded: stdout.writeLine("")
2018-05-11 18:39:40 -05:00
else:
for i in issues:
ctx.writeIssue(i, state, innerWidth, indent, topPadded)
2018-05-11 18:39:40 -05:00
topPadded = not i.details.isNilOrWhitespace
stdout.writeLine("")
2018-05-11 18:39:40 -05:00
2018-05-12 11:40:15 -05:00
proc loadIssues(ctx: CliContext, state: IssueState) =
ctx.issues[state] = loadIssues(ctx.tasksDir / $state)
2018-05-11 18:39:40 -05:00
proc loadAllIssues(ctx: CliContext) =
ctx.issues = newTable[IssueState, seq[Issue]]()
2018-05-12 11:40:15 -05:00
for state in IssueState: ctx.loadIssues(state)
2018-05-11 18:39:40 -05:00
proc filterIssues(ctx: CliContext, filter: IssueFilter) =
for state, issueList in ctx.issues:
ctx.issues[state] = issueList.filter(filter)
proc parsePropertiesOption(propsOpt: string): TableRef[string, string] =
result = newTable[string, string]()
for propText in propsOpt.split(";"):
let pair = propText.split(":", 1)
if pair.len == 1: result[pair[0]] = "true"
else: result[pair[0]] = pair[1]
2018-05-11 18:39:40 -05:00
proc sameDay(a, b: DateTime): bool =
result = a.year == b.year and a.yearday == b.yearday
proc writeHeader(ctx: CliContext, header: string) =
stdout.setForegroundColor(fgRed, true)
stdout.writeLine('_'.repeat(ctx.termWidth))
stdout.writeLine(header.center(ctx.termWidth))
stdout.writeLine('~'.repeat(ctx.termWidth))
stdout.resetAttributes
proc edit(issue: Issue) =
# Write format comments (to help when editing)
writeFile(issue.filepath, toStorageFormat(issue, true))
let editor =
if existsEnv("EDITOR"): getEnv("EDITOR")
else: "vi"
discard os.execShellCmd(editor & " " & issue.filepath & " </dev/tty >/dev/tty")
try:
# Try to parse the newly-edited issue to make sure it was successful.
let editedIssue = loadIssue(issue.filepath)
editedIssue.store()
except:
fatal "pit: updated issue is invalid (ignoring edits): \n\t" &
getCurrentExceptionMsg()
issue.store()
proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState], today, future: bool) =
if state.isSome:
ctx.loadIssues(state.get)
if filter.isSome: ctx.filterIssues(filter.get)
ctx.writeSection(ctx.issues[state.get], state.get)
return
ctx.loadAllIssues()
if filter.isSome: ctx.filterIssues(filter.get)
let indent = if today and future: " " else: ""
# Today's items
if today:
if future: ctx.writeHeader("Today")
for s in [Current, TodoToday]:
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
ctx.writeSection(ctx.issues[s], s, indent)
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:
ctx.writeSection(doneIssues, Done, indent)
# 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:
ctx.writeSection(ctx.issues[s], s, indent)
2018-05-12 11:40:15 -05:00
2018-05-11 18:39:40 -05:00
when isMainModule:
try:
let doc = """
Usage:
pit ( new | add) <summary> [<state>] [options]
2018-05-11 18:39:40 -05:00
pit list [<state>] [options]
pit ( start | done | pending | do-today | todo ) <id>...
pit edit <id>
pit delete <id>...
2018-05-11 18:39:40 -05:00
Options:
-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"
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.
2018-05-11 18:39:40 -05:00
-T, --today Limit to today's issues.
2018-05-12 11:40:15 -05:00
-F, --future Limit to future issues.
-y, --yes Automatically answer "yes" to any prompts.
-C, --config <cfgFile> Location of the config file (defaults to $HOME/.pitrc)
2018-05-11 18:39:40 -05:00
-E, --echo-args Echo arguments (for debug purposes).
2018-05-11 18:39:40 -05:00
--tasks-dir Path to the tasks directory (defaults to the value
configured in the .pitrc file)
2018-05-12 11:40:15 -05:00
--term-width Manually set the terminal width to use.
2018-05-11 18:39:40 -05:00
"""
logging.addHandler(newConsoleLogger())
# Parse arguments
let args = docopt(doc, version = "pit 4.0.2")
2018-05-11 18:39:40 -05:00
if args["--echo-args"]: stderr.writeLine($args)
2018-05-11 18:39:40 -05:00
if args["--help"]:
stderr.writeLine(doc)
2018-05-11 18:39:40 -05:00
quit()
let ctx = initContext(args)
## Actual command runners
if args["new"] or args["add"]:
let state =
if args["<state>"]: parseEnum[IssueState]($args["<state>"])
else: TodoToday
var issue = Issue(
id: genUUID(),
summary: $args["<summary>"],
properties:
if args["--properties"]: parsePropertiesOption($args["--properties"])
else: newTable[string,string](),
tags:
if args["--tags"]: ($args["tags"]).split(",").mapIt(it.strip)
else: newSeq[string]())
ctx.tasksDir.store(issue, state)
elif args["edit"]:
let issueId = $args["<id>"]
edit(ctx.tasksDir.loadIssueById(issueId))
elif args["start"] or args["do-today"] or args["done"] or
args["pending"] or args["todo"]:
var targetState: IssueState
if args["done"]: targetState = Done
elif args["do-today"]: targetState = TodoToday
elif args["pending"]: targetState = Todo
elif args["start"]: targetState = Current
elif args["todo"]: targetState = Todo
for id in @(args["<id>"]):
ctx.tasksDir.loadIssueById(id).changeState(ctx.tasksDir, targetState)
if ctx.triggerPtk:
if targetState == Current:
let issue = ctx.tasksDir.loadIssueById($(args["<id>"][0]))
var cmd = "ptk start "
if issue.tags.len > 0: cmd &= "-g \"" & issue.tags.join(",") & "\""
cmd &= " \"" & issue.summary & "\""
discard execShellCmd(cmd)
elif targetState == Done: discard execShellCmd("ptk stop")
elif args["delete"]:
for id in @(args["<id>"]):
let issue = ctx.tasksDir.loadIssueById(id)
if not args["--yes"]:
stderr.write("Delete '" & issue.summary & "' (y/n)? ")
if not "yes".startsWith(stdin.readLine.toLower):
continue
issue.delete
elif args["list"]:
let filter = initFilter()
var filterOption = none(IssueFilter)
if args["--properties"]:
filter.properties = parsePropertiesOption($args["--properties"])
filterOption = some(filter)
let stateOption =
if args["<state>"]: some(parseEnum[IssueState]($args["<state>"]))
else: none(IssueState)
let showBoth = args["--today"] == args["--future"]
ctx.list(filterOption, stateOption, showBoth or args["--today"],
showBoth or args["--future"])
if ctx.autoList and not args["list"]:
ctx.loadAllIssues()
ctx.list(none(IssueFilter), none(IssueState), true, true)
2018-05-11 18:39:40 -05:00
except:
fatal "pit: " & getCurrentExceptionMsg()
#raise getCurrentException()
quit(QuitFailure)