Compare commits

...

4 Commits
4.1.0 ... 4.4.0

Author SHA1 Message Date
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
6 changed files with 262 additions and 112 deletions

View File

@ -1,6 +1,6 @@
# Package
include "src/pitpkg/private/version.nim"
include "src/pitpkg/version.nim"
version = PIT_VERSION
author = "Jonathan Bernard"
@ -11,5 +11,5 @@ bin = @["pit", "pit_api"]
# Dependencies
requires @[ "nim >= 0.18.0", "cliutils 0.3.5", "docopt 0.6.5", "jester 0.2.0",
"timeutils 0.3.0", "uuids 0.1.9" ]
requires @[ "nim >= 0.18.0", "cliutils 0.4.1", "docopt 0.6.5", "jester 0.2.0",
"langutils >= 0.4.0", "timeutils 0.3.0", "uuids 0.1.9" ]

View File

@ -4,20 +4,27 @@
import cliutils, docopt, json, logging, options, os, ospaths, sequtils,
tables, terminal, times, timeutils, unicode, uuids
from nre import re
import strutils except capitalize, toUpper, toLower
import pitpkg/private/libpit
export libpit
include "pitpkg/private/version.nim"
include "pitpkg/version.nim"
type
CliContext = ref object
autoList, triggerPtk, verbose: bool
cfg*: PitConfig
contexts*: TableRef[string, string]
defaultContext*, tasksDir*: string
defaultContext*: Option[string]
tasksDir*: string
issues*: TableRef[IssueState, seq[Issue]]
termWidth*: int
triggerPtk*, verbose*: bool
let EDITOR =
if existsEnv("EDITOR"): getEnv("EDITOR")
else: "vi"
proc initContext(args: Table[string, Value]): CliContext =
let pitCfg = loadConfig(args)
@ -29,9 +36,10 @@ proc initContext(args: Table[string, Value]): CliContext =
let cliCfg = CombinedConfig(docopt: args, json: cliJson)
result = CliContext(
autoList: cliJson.getOrDefault("autoList").getBool(false),
contexts: pitCfg.contexts,
defaultContext: cliJson.getOrDefault("defaultContext").getStr(""),
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]](),
tasksDir: pitCfg.tasksDir,
@ -44,77 +52,93 @@ proc getIssueContextDisplayName(ctx: CliContext, context: string): string =
else: return context.capitalize()
return ctx.contexts[context]
proc writeIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
verbose = false, topPadded = false) =
var showDetails = not issue.details.isNilOrWhitespace and verbose
proc formatIssue(ctx: CliContext, issue: Issue): string =
result = ($issue.id).withColor(fgBlack, true) & "\n"&
issue.summary.withColor(fgWhite) & "\n"
if showDetails and not topPadded: stdout.writeLine("")
if issue.tags.len > 0:
result &= "tags: ".withColor(fgMagenta) &
issue.tags.join(",").withColor(fgGreen, true) & "\n"
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.isNilOrWhitespace:
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.isNilOrWhitespace and verbose
# 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(fgWhite, false)
stdout.write(wrappedSummary)
result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true)
result &= wrappedSummary.withColor(fgWhite)
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("")
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
var pendingText = issue["pending"].wordWrap(width - startIdx - 2)
.indent(startIdx)
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2)
stdout.setForegroundColor(fgCyan, false)
stdout.writeLine(pendingText)
result &= "\n" & pendingText.withColor(fgCyan)
if showDetails:
stdout.setForegroundColor(fgCyan, false)
stdout.writeLine(issue.details.indent(indent.len + 2))
result &= "\n" & issue.details.strip.indent(indent.len + 2).withColor(fgCyan)
stdout.resetAttributes
result &= termReset
proc writeSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
indent = "", verbose = false) =
proc formatSectionIssueList(ctx: CliContext, issues: seq[Issue], width: int,
indent: string, verbose: bool): string =
result = ""
var topPadded = true
for i in issues:
var issueText = ctx.formatSectionIssue(i, width, indent, verbose)
if issueText.splitLines.len > 1:
if topPadded: result &= issueText & "\n\n"
else: result &= "\n" & issueText & "\n\n"
topPadded = true
else:
result &= issueText & "\n"
topPadded = false
proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
indent = "", verbose = false): string =
let innerWidth = ctx.termWidth - (indent.len * 2)
stdout.setForegroundColor(fgBlue, true)
stdout.writeLine(indent & ".".repeat(innerWidth))
stdout.writeLine(state.displayName.center(ctx.termWidth))
stdout.writeLine("")
stdout.resetAttributes
result = termColor(fgBlue) &
(indent & ".".repeat(innerWidth)) & "\n" &
state.displayName.center(ctx.termWidth) & "\n\n" &
termReset
let issuesByContext = issues.groupBy("context")
var topPadded = true
if issues.len > 5 and issuesByContext.len > 1:
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:
ctx.writeIssue(i, innerWidth - 2, indent & " ", verbose, topPadded)
topPadded = not i.details.isNilOrWhitespace and verbose
result &= termColor(fgYellow) &
indent & ctx.getIssueContextDisplayName(context) & ":" &
termReset & "\n\n"
if not topPadded: stdout.writeLine("")
result &= ctx.formatSectionIssueList(ctxIssues, innerWidth - 2, indent & " ", verbose)
result &= "\n"
else:
for i in issues:
ctx.writeIssue(i, innerWidth, indent, verbose, topPadded)
topPadded = not i.details.isNilOrWhitespace and verbose
stdout.writeLine("")
else: result &= ctx.formatSectionIssueList(issues, innerWidth, indent, verbose)
proc loadIssues(ctx: CliContext, state: IssueState) =
ctx.issues[state] = loadIssues(ctx.tasksDir / $state)
@ -144,16 +168,18 @@ proc writeHeader(ctx: CliContext, header: string) =
stdout.writeLine('~'.repeat(ctx.termWidth))
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) =
# 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")
discard os.execShellCmd(EDITOR & " '" & issue.filepath & "' </dev/tty >/dev/tty")
try:
# Try to parse the newly-edited issue to make sure it was successful.
@ -164,17 +190,23 @@ proc edit(issue: Issue) =
getCurrentExceptionMsg()
issue.store()
proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState], today, future, verbose: bool) =
proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState], showToday, showFuture, verbose: bool) =
if state.isSome:
ctx.loadIssues(state.get)
if filter.isSome: ctx.filterIssues(filter.get)
ctx.writeSection(ctx.issues[state.get], state.get, "", verbose)
stdout.write ctx.formatSection(ctx.issues[state.get], state.get, "", verbose)
return
ctx.loadAllIssues()
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: ""
# Today's items
@ -183,14 +215,14 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState
for s in [Current, TodoToday]:
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
ctx.writeSection(ctx.issues[s], s, indent, verbose)
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:
ctx.writeSection(doneIssues, Done, indent, verbose)
stdout.write ctx.formatSection(doneIssues, Done, indent, verbose)
# Future items
if future:
@ -198,7 +230,7 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState
for s in [Pending, Todo]:
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
ctx.writeSection(ctx.issues[s], s, indent, verbose)
stdout.write ctx.formatSection(ctx.issues[s], s, indent, verbose)
when isMainModule:
@ -207,8 +239,9 @@ when isMainModule:
Usage:
pit ( new | add) <summary> [<state>] [options]
pit list [<listable>] [options]
pit ( start | done | pending | do-today | todo | suspend ) <id>... [options]
pit edit <id>
pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options]
pit edit <ref>...
pit reorder <state>
pit ( delete | rm ) <id>...
Options:
@ -228,6 +261,12 @@ Options:
-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.
@ -258,22 +297,20 @@ Options:
let ctx = initContext(args)
var propertiesOption = none(TableRef[string,string])
var tagsOption = none(seq[string])
if args["--properties"] or args["--context"] or
not ctx.defaultContext.isNilOrWhitespace:
if args["--properties"] or args["--context"]:
var props =
if args["--properties"]: parsePropertiesOption($args["--properties"])
else: newTable[string,string]()
if args["--context"] and $args["--context"] != "all":
props["context"] = $args["--context"]
elif not args["--context"] and not ctx.defaultContext.isNilOrWhitespace:
stderr.writeLine("Limiting to default context: " & ctx.defaultContext)
props["context"] = ctx.defaultContext
if args["--context"]: props["context"] = $args["--context"]
propertiesOption = some(props)
if args["--tags"]: tagsOption = some(($args["--tags"]).split(",").mapIt(it.strip))
## Actual command runners
if args["new"] or args["add"]:
let state =
@ -282,6 +319,9 @@ Options:
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(
id: genUUID(),
@ -293,17 +333,32 @@ Options:
ctx.tasksDir.store(issue, state)
stdout.writeLine ctx.formatIssue(issue)
elif args["reorder"]:
ctx.reorder(parseEnum[IssueState]($args["<state>"]))
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["start"] or args["todo-today"] or args["done"] or
args["pending"] or args["todo"] or args["suspend"]:
var targetState: IssueState
if args["done"]: targetState = Done
elif args["do-today"]: targetState = TodoToday
elif args["todo-today"]: targetState = TodoToday
elif args["pending"]: targetState = Pending
elif args["start"]: targetState = Current
elif args["todo"]: targetState = Todo
@ -314,6 +369,7 @@ Options:
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:
@ -342,20 +398,58 @@ Options:
let filter = initFilter()
var filterOption = none(IssueFilter)
# Initialize filter with properties (if given)
if propertiesOption.isSome:
filter.properties = propertiesOption.get
filterOption = some(filter)
# If they supplied text matches, add that to the filter.
if args["--match"]:
filter.summaryMatch = some(re("(?i)" & $args["--match"]))
filterOption = some(filter)
if args["--match-all"]:
filter.fullMatch = some(re("(?i)" & $args["--match-all"]))
filterOption = some(filter)
# If no "context" property is given, use the default (if we have one)
if ctx.defaultContext.isSome and not filter.properties.hasKey("context"):
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["<listable>"]:
try: stateOption = some(parseEnum[IssueState]($args["<listable>"]))
except: issueIdOption = some($args["<listable>"])
if $args["<listable>"] == "contexts": listContexts = true
else:
try: stateOption = some(parseEnum[IssueState]($args["<listable>"]))
except: issueIdOption = some($args["<listable>"])
# 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"])
for c in uniqContexts: stdout.writeLine(c)
# List a specific issue
if issueIdOption.isSome:
elif issueIdOption.isSome:
let issue = ctx.tasksDir.loadIssueById(issueIdOption.get)
ctx.writeIssue(issue, ctx.termWidth, "", true, true)
stdout.writeLine ctx.formatIssue(issue)
# List all issues
else:
@ -364,10 +458,6 @@ Options:
showBoth or args["--future"],
ctx.verbose)
if ctx.autoList and not args["list"]:
ctx.loadAllIssues()
ctx.list(none(IssueFilter), none(IssueState), true, true, false)
except:
fatal "pit: " & getCurrentExceptionMsg()
#raise getCurrentException()

View File

@ -6,7 +6,7 @@ import nre except toSeq
import pitpkg/private/libpit
include "pitpkg/private/version.nim"
include "pitpkg/version.nim"
type
PitApiCfg* = object

View File

@ -1,7 +1,8 @@
import cliutils, docopt, json, logging, options, os, ospaths, sequtils,
strutils, tables, times, timeutils, uuids
import cliutils, docopt, json, logging, langutils, options, os, ospaths,
sequtils, strutils, tables, times, timeutils, uuids
from nre import find, match, re, Regex
from nre import re, match
type
Issue* = ref object
id*: UUID
@ -19,8 +20,9 @@ type
Dormant = "dormant"
IssueFilter* = ref object
completedRange*: Option[tuple[b, e: DateTime]]
fullMatch*, summaryMatch*: Option[Regex]
properties*: TableRef[string, string]
completedRange*: tuple[b, e: DateTime]
PitConfig* = ref object
tasksDir*: string
@ -62,22 +64,38 @@ proc setDateTime*(issue: Issue, key: string, dt: DateTime) =
proc initFilter*(): IssueFilter =
result = IssueFilter(
properties: newTable[string,string](),
completedRange: (fromUnix(0).local, fromUnix(253400659199).local))
completedRange: none(tuple[b, e: DateTime]),
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):
raise newException(ValueError,
"cannot initialize property filter without properties")
result = IssueFilter(
properties: props,
completedRange: (fromUnix(0).local, fromUnix(253400659199).local))
result = initFilter()
result.properties = props
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
proc fromStorageFormat*(id: string, issueTxt: string): Issue =
@ -162,11 +180,51 @@ proc store*(tasksDir: string, issue: Issue, state: IssueState, withComments = fa
issue.store()
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] =
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.isNilOrWhitespace)
else: newSeq[string]()
type TaggedIssue = tuple[issue: Issue, ordered: bool]
var unorderedIssues: seq[TaggedIssue] = @[]
for path in walkDirRec(path):
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) =
let oldFilepath = issue.filepath
@ -177,23 +235,25 @@ proc changeState*(issue: Issue, tasksDir: string, newState: IssueState) =
proc delete*(issue: Issue) = removeFile(issue.filepath)
## 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] =
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))
if filter.completedRange.isSome:
let range = filter.completedRange.get
result = result.filterIt(
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 =

View File

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

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

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