Implemented new, edit, state transitions.

This commit is contained in:
Jonathan Bernard 2018-05-13 02:58:08 -05:00
parent d86da67284
commit 11b18317bd
3 changed files with 276 additions and 94 deletions

View File

@ -9,4 +9,4 @@ bin = @["pit"]
# Dependencies # Dependencies
requires @["nim >= 0.18.1", "uuids 0.1.9", "docopt 0.6.5", "cliutils 0.3.3"] requires @["nim >= 0.18.0", "uuids 0.1.9", "docopt 0.6.5", "cliutils 0.3.4", "timeutils 0.3.0"]

View File

@ -2,19 +2,20 @@
## ====================== ## ======================
## ##
import cliutils, docopt, json, logging, os, ospaths, sequtils, strutils, import cliutils, docopt, json, logging, options, os, ospaths, sequtils,
tables, times, unicode, uuids tables, terminal, times, unicode, uuids
import strutils except capitalize
import pit/private/libpit import pit/private/libpit
export libpit export libpit
type type
CliContext = ref object CliContext = ref object
autoList, triggerPtk: bool
tasksDir*: string tasksDir*: string
contexts*: TableRef[string, string] contexts*: TableRef[string, string]
issues*: TableRef[IssueState, seq[Issue]] issues*: TableRef[IssueState, seq[Issue]]
termWidth*: int termWidth*: int
cfg*: CombinedConfig
proc initContext(args: Table[string, Value]): CliContext = proc initContext(args: Table[string, Value]): CliContext =
let pitrcLocations = @[ let pitrcLocations = @[
@ -44,11 +45,12 @@ proc initContext(args: Table[string, Value]): CliContext =
let cfg = CombinedConfig(docopt: args, json: cfgJson) let cfg = CombinedConfig(docopt: args, json: cfgJson)
result = CliContext( result = CliContext(
cfg: cfg, autoList: cfgJson.getOrDefault("autoList").getBool(false),
tasksDir: cfg.getVal("tasks-dir", ""),
contexts: newTable[string,string](), contexts: newTable[string,string](),
issues: newTable[IssueState, seq[Issue]](), issues: newTable[IssueState, seq[Issue]](),
termWidth: parseInt(cfg.getVal("term-width", "80"))) tasksDir: cfg.getVal("tasks-dir", ""),
termWidth: parseInt(cfg.getVal("term-width", "80")),
triggerPtk: cfgJson.getOrDefault("triggerPtk").getBool(false))
if cfgJson.hasKey("contexts"): if cfgJson.hasKey("contexts"):
for k, v in cfgJson["contexts"]: for k, v in cfgJson["contexts"]:
@ -61,39 +63,54 @@ proc initContext(args: Table[string, Value]): CliContext =
raise newException(Exception, "cannot find tasks dir: " & 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): return context.capitalize() if not ctx.contexts.hasKey(context):
if context.isNilOrWhitespace: return "<default>"
else: return context.capitalize()
return ctx.contexts[context] return ctx.contexts[context]
proc formatIssue(ctx: CliContext, issue: Issue, state: IssueState, proc writeIssue(ctx: CliContext, issue: Issue, state: IssueState,
width: int, indent: string, topPadded: bool): string = width: int, indent: string, topPadded: bool) =
var showDetails = not issue.details.isNilOrWhitespace var showDetails = not issue.details.isNilOrWhitespace
var lines: seq[string] = @[]
if showDetails and not topPadded: lines.add("") if showDetails and not topPadded: stdout.writeLine("")
var wrappedSummary = issue.summary.wordWrap(width - 2).indent(2) # Wrap and write the summary.
wrappedSummary = "*" & wrappedSummary[1..^1] var wrappedSummary = (" ".repeat(5) & issue.summary).wordWrap(width - 2).indent(2 + indent.len)
lines.add(wrappedSummary.indent(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 state == Pending and issue.properties.hasKey("pending"): 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
if state == Pending and issue.hasProp("pending"):
let startIdx = "Pending: ".len let startIdx = "Pending: ".len
var pendingText = issue["pending"].wordWrap(width - startIdx - 2) var pendingText = issue["pending"].wordWrap(width - startIdx - 2)
.indent(startIdx) .indent(startIdx)
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2) pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2)
lines.add(pendingText) stdout.writeLine(pendingText)
if showDetails: lines.add(issue.details.indent(indent.len + 2)) if showDetails: stdout.writeLine(issue.details.indent(indent.len + 2))
return lines.join("\n")
proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState, proc writeSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
indent = ""): string = indent = "") =
let innerWidth = ctx.termWidth - (indent.len * 2) let innerWidth = ctx.termWidth - (indent.len * 2)
var lines: seq[string] = @[]
lines.add(indent & ".".repeat(innerWidth)) stdout.setForegroundColor(fgBlue, true)
lines.add(state.displayName.center(ctx.termWidth)) stdout.writeLine(indent & ".".repeat(innerWidth))
lines.add("") stdout.writeLine(state.displayName.center(ctx.termWidth))
stdout.writeLine("")
stdout.resetAttributes
var topPadded = true var topPadded = true
@ -101,64 +118,140 @@ proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
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:
lines.add(indent & ctx.getIssueContextDisplayName(context) & ":") stdout.setForegroundColor(fgYellow, false)
lines.add("") stdout.writeLine(indent & ctx.getIssueContextDisplayName(context) & ":")
stdout.writeLine("")
stdout.resetAttributes
for i in ctxIssues: for i in ctxIssues:
lines.add(ctx.formatIssue(i, state, innerWidth - 2, indent & " ", topPadded)) ctx.writeIssue(i, state, innerWidth - 2, indent & " ", topPadded)
topPadded = not i.details.isNilOrWhitespace topPadded = not i.details.isNilOrWhitespace
if not topPadded: lines.add("") if not topPadded: stdout.writeLine("")
else: else:
for i in issues: for i in issues:
lines.add(ctx.formatIssue(i, state, innerWidth, indent, topPadded)) ctx.writeIssue(i, state, innerWidth, indent, topPadded)
topPadded = not i.details.isNilOrWhitespace topPadded = not i.details.isNilOrWhitespace
lines.add("") stdout.writeLine("")
return lines.join("\n")
proc loadIssues(ctx: CliContext, state: IssueState) = proc loadIssues(ctx: CliContext, state: IssueState) =
ctx.issues[state] = loadIssues(joinPath(ctx.tasksDir, $state)) ctx.issues[state] = loadIssues(ctx.tasksDir / $state)
proc loadAllIssues(ctx: CliContext) = proc loadAllIssues(ctx: CliContext) =
ctx.issues = newTable[IssueState, seq[Issue]]()
for state in IssueState: ctx.loadIssues(state) for state in IssueState: ctx.loadIssues(state)
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]
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
proc formatHeader(ctx: CliContext, header: string): string = proc writeHeader(ctx: CliContext, header: string) =
var lines: seq[string] = @[] stdout.setForegroundColor(fgRed, true)
lines.add('_'.repeat(ctx.termWidth)) stdout.writeLine('_'.repeat(ctx.termWidth))
lines.add(header.center(ctx.termWidth)) stdout.writeLine(header.center(ctx.termWidth))
lines.add('~'.repeat(ctx.termWidth)) stdout.writeLine('~'.repeat(ctx.termWidth))
lines.add("") stdout.resetAttributes
return lines.join("\n")
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)
when isMainModule: when isMainModule:
try: try:
let doc = """ let doc = """
Usage: Usage:
pit new <state> <summary> [options] pit new <summary> [<state>] [options]
pit list [<state>] [options] pit list [<state>] [options]
pit today pit ( start | done | pending | do-today | todo ) <id>...
pit start pit edit <id>
pit done
pit pending
pit edit
Options: Options:
-t, --tags <tags> Specify tags for an issue.
-p, --properties <props> Specify properties for an issue. Formatted as "key:val;key:val"
-C, --config <cfgFile> Location of the config file (defaults to $HOME/.pitrc)
-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"
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.
-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.
-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 --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 Manually set the terminal width to use.
""" """
@ -167,53 +260,78 @@ Options:
# Parse arguments # Parse arguments
let args = docopt(doc, version = "ptk 0.12.1") let args = docopt(doc, version = "ptk 0.12.1")
if args["--echo-args"]: echo $args if args["--echo-args"]: stderr.writeLine($args)
if args["--help"]: if args["--help"]:
echo doc stderr.writeLine(doc)
quit() quit()
let now = getTime().local
let ctx = initContext(args) let ctx = initContext(args)
## Actual command runners ## Actual command runners
if args["list"]: if args["new"]:
let state =
if args["<state>"]: parseEnum[IssueState]($args["<state>"])
else: TodoToday
# Specific state request var issue = Issue(
if args["<state>"]: id: genUUID(),
let state = parseEnum[IssueState]($args["<state>"]) summary: $args["<summary>"],
ctx.loadIssues(state) properties:
echo ctx.formatSection(ctx.issues[state], state) if args["--properties"]: parsePropertiesOption($args["--properties"])
else: newTable[string,string](),
tags:
if args["--tags"]: ($args["tags"]).split(",").mapIt(it.strip)
else: newSeq[string]())
else: 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.moveIssue(ctx.tasksDir.loadIssueById(id), 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["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"] let showBoth = args["--today"] == args["--future"]
let indent = if showBoth: " " else: "" ctx.list(filterOption, stateOption, showBoth or args["--today"],
showBoth or args["--future"])
if ctx.autoList and not args["list"]:
ctx.loadAllIssues() ctx.loadAllIssues()
ctx.list(none(IssueFilter), none(IssueState), true, true)
# Today's items
if args["--today"] or showBoth:
if showBoth: echo ctx.formatHeader("Today")
for s in [Current, TodoToday]:
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
echo ctx.formatSection(ctx.issues[s], s, indent)
if ctx.issues.hasKey(Done):
let doneIssues = ctx.issues[Done].filterIt(
it.properties.hasKey("completed") and
sameDay(now, it.getDateTime("completed")))
if doneIssues.len > 0:
echo ctx.formatSection(doneIssues, Done, indent)
# Future items
if args["--future"] or showBoth:
if showBoth: echo ctx.formatHeader("Future")
for s in [Pending, Todo]:
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
echo ctx.formatSection(ctx.issues[s], s, indent)
except: except:
fatal "pit: " & getCurrentExceptionMsg() fatal "pit: " & getCurrentExceptionMsg()

View File

@ -1,4 +1,4 @@
import cliutils, options, os, ospaths, sequtils, strutils, tables, times, uuids import cliutils, options, os, ospaths, sequtils, strutils, tables, times, timeutils, uuids
from nre import re, match from nre import re, match
type type
@ -16,7 +16,10 @@ type
Done = "done", Done = "done",
Todo = "todo" Todo = "todo"
const ISO8601Format* = "yyyy:MM:dd'T'HH:mm:sszzz" IssueFilter* = ref object
properties*: TableRef[string, string]
completedRange*: tuple[b, e: DateTime]
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"
@ -36,11 +39,37 @@ 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
proc hasProp*(issue: Issue, key: string): bool =
return issue.properties.hasKey(key)
proc getDateTime*(issue: Issue, key: string): DateTime = proc getDateTime*(issue: Issue, key: string): DateTime =
return parse(issue.properties[key], ISO8601Format) return issue.properties[key].parseIso8601
proc getDateTime*(issue: Issue, key: string, default: DateTime): DateTime =
if issue.properties.hasKey(key): return issue.properties[key].parseIso8601
else: return default
proc setDateTime*(issue: Issue, key: string, dt: DateTime) = proc setDateTime*(issue: Issue, key: string, dt: DateTime) =
issue.properties[key] = format(dt, ISO8601Format) issue.properties[key] = dt.formatIso8601
proc initFilter*(): IssueFilter =
result = IssueFilter(
properties: newTable[string,string](),
completedRange: (fromUnix(0).local, fromUnix(253400659199).local))
proc initFilter*(props: TableRef[string, string]): IssueFilter =
if isNil(props):
raise newException(ValueError,
"cannot initialize property filter without properties")
result = IssueFilter(
properties: props,
completedRange: (fromUnix(0).local, fromUnix(253400659199).local))
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 =
@ -86,11 +115,15 @@ proc fromStorageFormat*(id: string, issueTxt: string): Issue =
result.details = if detailLines.len > 0: detailLines.join("\n") else: "" result.details = if detailLines.len > 0: detailLines.join("\n") else: ""
proc toStorageFormat*(issue: Issue): string = proc toStorageFormat*(issue: Issue, withComments = false): string =
var lines = @[issue.summary] var lines: seq[string] = @[]
if withComments: lines.add("# Summary (one line):")
lines.add(issue.summary)
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: 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): if not isNilOrWhitespace(issue.details) or withComments:
if withComments: lines.add("# Details go below the \"--------\"")
lines.add("--------") lines.add("--------")
lines.add(issue.details) lines.add(issue.details)
@ -101,9 +134,25 @@ proc loadIssue*(filePath: string): Issue =
result = fromStorageFormat(splitFile(filePath).name, readFile(filePath)) result = fromStorageFormat(splitFile(filePath).name, readFile(filePath))
result.filepath = filePath result.filepath = filePath
proc storeIssue*(dirPath: string, issue: Issue) = proc loadIssueById*(tasksDir, id: string): Issue =
issue.filepath = joinPath(dirPath, $issue.id & ".txt") for path in walkDirRec(tasksDir):
writeFile(issue.filepath, toStorageFormat(issue)) if path.splitFile.name.startsWith(id):
return loadIssue(path)
raise newException(KeyError, "cannot find issue for id: " & id)
proc store*(issue: Issue, withComments = false) =
writeFile(issue.filepath, toStorageFormat(issue, withComments))
proc store*(tasksDir: string, issue: Issue, state: IssueState, withComments = false) =
let stateDir = tasksDir / $state
let filename = $issue.id & ".txt"
if state == Done:
let monthPath = issue.getDateTime("completed", getTime().local).format(DONE_FOLDER_FORMAT)
issue.filepath = stateDir / monthPath / filename
else:
issue.filepath = stateDir / filename
issue.store()
proc loadIssues*(path: string): seq[Issue] = proc loadIssues*(path: string): seq[Issue] =
result = @[] result = @[]
@ -111,12 +160,27 @@ proc loadIssues*(path: string): seq[Issue] =
if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome(): if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome():
result.add(loadIssue(path)) result.add(loadIssue(path))
proc moveIssue*(tasksDir: string, issue: Issue, newState: IssueState) =
removeFile(issue.filepath)
if newState == Done: issue.setDateTime("completed", getTime().local)
tasksDir.store(issue, newState)
## Utilities for working with issue collections. ## Utilities for working with issue collections.
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:
let key = if i.properties.hasKey(propertyKey): i[propertyKey] else: "" let key = if i.hasProp(propertyKey): i[propertyKey] else: ""
if not result.hasKey(key): result[key] = newSeq[Issue]() if not result.hasKey(key): result[key] = newSeq[Issue]()
result[key].add(i) result[key].add(i)
proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
result = issues
for k,v in filter.properties:
result = result.filterIt(it.hasProp(k) and it[k] == v)
result = result.filterIt(not it.hasProp("completed") or
it.getDateTime("completed").between(
filter.completedRange.b,
filter.completedRange.e))