462 lines
14 KiB
Nim
462 lines
14 KiB
Nim
## Personal Issue Tracker CLI interface
|
|
## ====================================
|
|
|
|
import std/[algorithm, logging, options, os, sequtils, tables, times, unicode]
|
|
import data_uri, docopt, json, timeutils, uuids
|
|
|
|
from nre import re
|
|
import strutils except alignLeft, capitalize, strip, toUpper, toLower
|
|
import pit/[cliconstants, formatting, libpit, sync_pbm_vsb]
|
|
|
|
export formatting, libpit
|
|
|
|
let EDITOR =
|
|
if existsEnv("EDITOR"): getEnv("EDITOR")
|
|
else: "vi"
|
|
|
|
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 parseExclPropertiesOption(propsOpt: string): TableRef[string, seq[string]] =
|
|
result = newTable[string, seq[string]]()
|
|
for propText in propsOpt.split(";"):
|
|
let pair = propText.split(":", 1)
|
|
let val =
|
|
if pair.len == 1: "true"
|
|
else: pair[1]
|
|
if result.hasKey(pair[0]): result[pair[0]].add(val)
|
|
else: result[pair[0]] = @[val]
|
|
|
|
|
|
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.cfg.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))
|
|
|
|
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 CatchableError:
|
|
fatal "updated issue is invalid (ignoring edits): \n\t" &
|
|
getCurrentExceptionMsg()
|
|
issue.store()
|
|
|
|
when isMainModule:
|
|
try:
|
|
|
|
let consoleLogger = newConsoleLogger(
|
|
levelThreshold=lvlInfo,
|
|
fmtStr="pit - $levelname: ")
|
|
logging.addHandler(consoleLogger)
|
|
|
|
# Parse arguments
|
|
let args = docopt(USAGE, version = PIT_VERSION)
|
|
|
|
if args["--debug"]:
|
|
consoleLogger.levelThreshold = lvlDebug
|
|
|
|
if args["--silent"]:
|
|
consoleLogger.levelThreshold = lvlNone
|
|
|
|
if args["--echo-args"]: stderr.writeLine($args)
|
|
|
|
if args["help"]:
|
|
stderr.writeLine(USAGE & "\p")
|
|
stderr.writeLine(ONLINE_HELP)
|
|
quit()
|
|
|
|
let ctx = initContext(args)
|
|
|
|
trace "context initiated"
|
|
|
|
var updatedIssues = newSeq[Issue]()
|
|
var propertiesOption = none(TableRef[string,string])
|
|
var exclPropsOption = none(TableRef[string,seq[string]])
|
|
var tagsOption = none(seq[string])
|
|
var exclTagsOption = none(seq[string])
|
|
|
|
let filter = initFilter()
|
|
var filterOption = none(IssueFilter)
|
|
|
|
|
|
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["--excl-properties"] or args["--excl-context"]:
|
|
|
|
var exclProps =
|
|
if args["--excl-properties"]:
|
|
parseExclPropertiesOption($args["--excl-properties"])
|
|
else: newTable[string,seq[string]]()
|
|
|
|
if args["--excl-context"]:
|
|
if not exclProps.hasKey("context"): exclProps["context"] = @[]
|
|
let exclContexts = split($args["--excl-context"], ",")
|
|
exclProps["context"] = exclProps["context"].concat(exclContexts)
|
|
|
|
exclPropsOption = some(exclProps)
|
|
|
|
if args["--tags"]: tagsOption = some(($args["--tags"]).split(",").mapIt(it.strip))
|
|
|
|
if args["--excl-tags"]: exclTagsOption =
|
|
some(($args["--excl-tags"]).split(",").mapIt(it.strip))
|
|
|
|
# Initialize filter with properties (if given)
|
|
if propertiesOption.isSome:
|
|
filter.properties = propertiesOption.get
|
|
filterOption = some(filter)
|
|
|
|
# Add property exclusions (if given)
|
|
if exclPropsOption.isSome:
|
|
filter.exclProperties = exclPropsOption.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)
|
|
|
|
if tagsOption.isSome:
|
|
filter.hasTags = tagsOption.get
|
|
filterOption = some(filter)
|
|
|
|
if exclTagsOption.isSome:
|
|
filter.exclTags = exclTagsOption.get
|
|
filterOption = some(filter)
|
|
|
|
if args["--today"]:
|
|
filter.inclStates.add(@[Current, TodoToday, Pending])
|
|
filterOption = some(filter)
|
|
|
|
if args["--future"]:
|
|
filter.inclStates.add(@[Pending, Todo])
|
|
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")
|
|
filter.exclProperties.del("context")
|
|
|
|
## Actual command runners
|
|
if args["new"] or args["add"]:
|
|
let state =
|
|
if args["<state>"]: parseEnum[IssueState]($args["<state>"])
|
|
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(
|
|
id: genUUID(),
|
|
summary: $args["<summary>"],
|
|
properties: issueProps,
|
|
tags:
|
|
if tagsOption.isSome: tagsOption.get
|
|
else: newSeq[string]())
|
|
|
|
ctx.cfg.tasksDir.store(issue, state)
|
|
updatedIssues.add(issue)
|
|
stdout.writeLine formatIssue(issue)
|
|
|
|
elif args["reorder"]:
|
|
ctx.reorder(parseEnum[IssueState]($args["<state>"]))
|
|
|
|
elif args["edit"]:
|
|
for editRef in @(args["<ref>"]):
|
|
|
|
let propsOption =
|
|
if args["--properties"]:
|
|
some(parsePropertiesOption($args["--properties"]))
|
|
else: none(TableRef[string, string])
|
|
|
|
var stateOption = none(IssueState)
|
|
|
|
try: stateOption = some(parseEnum[IssueState](editRef))
|
|
except CatchableError: discard
|
|
|
|
if stateOption.isSome:
|
|
let state = stateOption.get
|
|
ctx.loadIssues(state)
|
|
for issue in ctx.issues[state]:
|
|
if propsOption.isSome:
|
|
for k,v in propsOption.get:
|
|
issue[k] = v
|
|
edit(issue)
|
|
updatedIssues.add(issue)
|
|
|
|
else:
|
|
let issue = ctx.cfg.tasksDir.loadIssueById(editRef)
|
|
if propertiesOption.isSome:
|
|
for k,v in propertiesOption.get:
|
|
issue[k] = v
|
|
edit(issue)
|
|
updatedIssues.add(issue)
|
|
|
|
elif args["tag"]:
|
|
if tagsOption.isNone: raise newException(Exception, "no tags given")
|
|
|
|
let newTags = tagsOption.get
|
|
|
|
for id in @(args["<id>"]):
|
|
var issue = ctx.cfg.tasksDir.loadIssueById(id)
|
|
issue.tags = deduplicate(issue.tags & newTags)
|
|
issue.store()
|
|
updatedIssues.add(issue)
|
|
|
|
elif args["untag"]:
|
|
let tagsToRemove: seq[string] =
|
|
if tagsOption.isSome: tagsOption.get
|
|
else: @[]
|
|
|
|
for id in @(args["<id>"]):
|
|
var issue = ctx.cfg.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()
|
|
updatedIssues.add(issue)
|
|
|
|
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["todo-today"]: targetState = TodoToday
|
|
elif args["pending"]: targetState = Pending
|
|
elif args["start"]: targetState = Current
|
|
elif args["todo"]: targetState = Todo
|
|
elif args["suspend"]: targetState = Dormant
|
|
|
|
for id in @(args["<id>"]):
|
|
var issue = ctx.cfg.tasksDir.loadIssueById(id)
|
|
if propertiesOption.isSome:
|
|
for k,v in propertiesOption.get:
|
|
issue[k] = v
|
|
if targetState == Done:
|
|
issue["completed"] = getTime().local.formatIso8601
|
|
if issue.hasProp("recurrence") and issue.getRecurrence.isSome:
|
|
let nextIssue = ctx.cfg.tasksDir.nextRecurrence(issue.getRecurrence.get, issue)
|
|
ctx.cfg.tasksDir.store(nextIssue, TodoToday)
|
|
info "created the next recurrence:"
|
|
updatedIssues.add(nextIssue)
|
|
stdout.writeLine formatIssue(nextIssue)
|
|
|
|
issue.changeState(ctx.cfg.tasksDir, targetState)
|
|
updatedIssues.add(issue)
|
|
|
|
if ctx.triggerPtk or args["--ptk"]:
|
|
if targetState == Current:
|
|
let issue = ctx.cfg.tasksDir.loadIssueById($(args["<id>"][0]))
|
|
var cmd = "ptk start"
|
|
if issue.tags.len > 0 or issue.hasProp("context"):
|
|
let tags = concat(
|
|
issue.tags,
|
|
if issue.hasProp("context"): @[issue.properties["context"]]
|
|
else: @[]
|
|
)
|
|
cmd &= " -g \"" & tags.join(",") & "\""
|
|
cmd &= " -n \"pit-id: " & $issue.id & "\""
|
|
cmd &= " \"[" & ($issue.id)[0..<6] & "] " & issue.summary & "\""
|
|
discard execShellCmd(cmd)
|
|
elif targetState == Done or targetState == Pending:
|
|
discard execShellCmd("ptk stop")
|
|
|
|
elif args["hide-until"]:
|
|
|
|
let issue = ctx.cfg.tasksDir.loadIssueById($(args["<id>"]))
|
|
issue.setDateTime("hide-until", parseDate($args["<date>"]))
|
|
|
|
issue.store()
|
|
updatedIssues.add(issue)
|
|
|
|
elif args["delegate"]:
|
|
|
|
let issue = ctx.cfg.tasksDir.loadIssueById($(args["<id>"]))
|
|
issue["delegated-to"] = $args["<delegated-to>"]
|
|
|
|
issue.store()
|
|
updatedIssues.add(issue)
|
|
|
|
elif args["delete"] or args["rm"]:
|
|
for id in @(args["<id>"]):
|
|
|
|
let issue = ctx.cfg.tasksDir.loadIssueById(id)
|
|
|
|
if not args["--yes"]:
|
|
stderr.write("Delete '" & issue.summary & "' (y/n)? ")
|
|
if not "yes".startsWith(stdin.readLine.toLower):
|
|
continue
|
|
|
|
issue.delete
|
|
updatedIssues.add(issue)
|
|
|
|
elif args["list"]:
|
|
|
|
var listContexts = false
|
|
var listTags = false
|
|
var statesOption = none(seq[IssueState])
|
|
var issueIdsOption = none(seq[string])
|
|
|
|
if args["contexts"]: listContexts = true
|
|
elif args["tags"]: listTags = true
|
|
elif args["<stateOrId>"]:
|
|
try:
|
|
statesOption =
|
|
some(args["<stateOrId>"].
|
|
mapIt(parseEnum[IssueState]($it)))
|
|
except CatchableError:
|
|
issueIdsOption = some(args["<stateOrId>"].mapIt($it))
|
|
|
|
# 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))
|
|
|
|
elif listTags:
|
|
var uniqTags = newseq[string]()
|
|
if statesOption.isSome:
|
|
for state in statesOption.get: ctx.loadIssues(state)
|
|
else: ctx.loadAllIssues()
|
|
|
|
if filterOption.isSome: ctx.filterIssues(filterOption.get)
|
|
|
|
for state, issueList in ctx.issues:
|
|
for issue in issueList:
|
|
for tag in issue.tags:
|
|
if not uniqTags.contains(tag): uniqTags.add(tag)
|
|
|
|
stdout.writeLine(uniqTags.sorted.join("\n"))
|
|
|
|
# List a specific issue
|
|
elif issueIdsOption.isSome:
|
|
for issueId in issueIdsOption.get:
|
|
let issue = ctx.cfg.tasksDir.loadIssueById(issueId)
|
|
stdout.writeLine formatIssue(issue)
|
|
|
|
# List all issues
|
|
else:
|
|
trace "listing all issues"
|
|
let showBoth = args["--today"] == args["--future"]
|
|
ctx.list(
|
|
filter = filterOption,
|
|
states = statesOption,
|
|
showToday = showBoth or args["--today"],
|
|
showFuture = showBoth or args["--future"],
|
|
showHidden = args["--show-hidden"],
|
|
verbose = ctx.verbose)
|
|
|
|
elif args["add-binary-property"]:
|
|
let issue = ctx.cfg.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()
|
|
updatedIssues.add(issue)
|
|
|
|
elif args["get-binary-property"]:
|
|
let issue = ctx.cfg.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)
|
|
|
|
elif args["show-dupes"]:
|
|
ctx.loadAllIssues()
|
|
|
|
var idsToPaths = newTable[string, var seq[string]]()
|
|
for (state, issues) in pairs(ctx.issues):
|
|
for issue in issues:
|
|
let issueId = $issue.id
|
|
|
|
if idsToPaths.hasKey(issueId): idsToPaths[issueId].add(issue.filepath)
|
|
else: idsToPaths[issueId] = @[issue.filepath]
|
|
|
|
for (issueId, issuePaths) in pairs(idsToPaths):
|
|
if issuePaths.len < 2: continue
|
|
stdout.writeLine(issueId & ":\p " & issuePaths.join("\p ") & "\p\p")
|
|
|
|
elif args["sync"]:
|
|
if ctx.cfg.syncTargets.len == 0:
|
|
info "No sync targets configured"
|
|
|
|
for syncTarget in ctx.cfg.syncTargets:
|
|
let syncCtx = initSyncContext(ctx.cfg, syncTarget)
|
|
|
|
sync(syncCtx, args["--dry-run"])
|
|
|
|
# after doing stuff, sync if auto-sync is requested
|
|
if ctx.cfg.autoSync:
|
|
for syncTarget in ctx.cfg.syncTargets:
|
|
let syncCtx = initSyncContext(ctx.cfg, syncTarget)
|
|
if anyIt(
|
|
updatedIssues,
|
|
it.hasProp("context") and it["context"] == syncCtx.issueContext):
|
|
sync(syncCtx, false)
|
|
|
|
except CatchableError:
|
|
fatal getCurrentExceptionMsg()
|
|
debug getCurrentException().getStackTrace()
|
|
#raise getCurrentException()
|
|
quit(QuitFailure)
|