|
|
|
@ -1,8 +1,9 @@
|
|
|
|
|
## Personal Issue Tracker CLI interface
|
|
|
|
|
## ====================================
|
|
|
|
|
|
|
|
|
|
import algorithm, cliutils, data_uri, docopt, json, logging, options, os,
|
|
|
|
|
sequtils, std/wordwrap, tables, terminal, times, timeutils, unicode, uuids
|
|
|
|
|
import std/algorithm, std/logging, std/options, std/os, std/sequtils,
|
|
|
|
|
std/wordwrap, std/tables, std/terminal, std/times, std/unicode
|
|
|
|
|
import cliutils, data_uri, docopt, json, timeutils, uuids
|
|
|
|
|
|
|
|
|
|
from nre import re
|
|
|
|
|
import strutils except alignLeft, capitalize, strip, toUpper, toLower
|
|
|
|
@ -15,7 +16,6 @@ type
|
|
|
|
|
cfg*: PitConfig
|
|
|
|
|
contexts*: TableRef[string, string]
|
|
|
|
|
defaultContext*: Option[string]
|
|
|
|
|
tasksDir*: string
|
|
|
|
|
issues*: TableRef[IssueState, seq[Issue]]
|
|
|
|
|
termWidth*: int
|
|
|
|
|
triggerPtk*, verbose*: bool
|
|
|
|
@ -42,7 +42,6 @@ proc initContext(args: Table[string, Value]): CliContext =
|
|
|
|
|
else: some(cliJson["defaultContext"].getStr()),
|
|
|
|
|
verbose: parseBool(cliCfg.getVal("verbose", "false")) and not args["--quiet"],
|
|
|
|
|
issues: newTable[IssueState, seq[Issue]](),
|
|
|
|
|
tasksDir: pitCfg.tasksDir,
|
|
|
|
|
termWidth: parseInt(cliCfg.getVal("termWidth", "80")),
|
|
|
|
|
triggerPtk: cliJson.getOrDefault("triggerPtk").getBool(false))
|
|
|
|
|
|
|
|
|
@ -71,46 +70,68 @@ proc formatIssue(ctx: CliContext, issue: Issue): string =
|
|
|
|
|
|
|
|
|
|
result &= termReset
|
|
|
|
|
|
|
|
|
|
proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
|
|
|
|
|
proc formatSectionIssue(
|
|
|
|
|
ctx: CliContext,
|
|
|
|
|
issue: Issue,
|
|
|
|
|
width: int,
|
|
|
|
|
indent = "",
|
|
|
|
|
verbose = false): string =
|
|
|
|
|
|
|
|
|
|
result = ""
|
|
|
|
|
|
|
|
|
|
var showDetails = not issue.details.isEmptyOrWhitespace and verbose
|
|
|
|
|
|
|
|
|
|
var prefixLen = 0
|
|
|
|
|
var summaryIndentLen = indent.len + 7
|
|
|
|
|
|
|
|
|
|
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).wrapWords(width - summaryIndentLen).indent(summaryIndentLen)
|
|
|
|
|
|
|
|
|
|
wrappedSummary = wrappedSummary[(prefixLen + summaryIndentLen)..^1]
|
|
|
|
|
|
|
|
|
|
result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true) & " "
|
|
|
|
|
|
|
|
|
|
if issue.hasProp("delegated-to"):
|
|
|
|
|
result &= (issue["delegated-to"] & ": ").withColor(fgGreen)
|
|
|
|
|
let showDetails = not issue.details.isEmptyOrWhitespace and verbose
|
|
|
|
|
|
|
|
|
|
result &= wrappedSummary.withColor(fgWhite)
|
|
|
|
|
let summaryIndentLen = indent.len + 7
|
|
|
|
|
let summaryWidth = width - summaryIndentLen
|
|
|
|
|
|
|
|
|
|
let summaryLines = issue.summary
|
|
|
|
|
.wrapWords(summaryWidth)
|
|
|
|
|
.splitLines
|
|
|
|
|
|
|
|
|
|
result &= summaryLines[0].withColor(fgWhite)
|
|
|
|
|
|
|
|
|
|
for line in summaryLines[1..^1]:
|
|
|
|
|
result &= "\p" & line.indent(summaryIndentLen)
|
|
|
|
|
|
|
|
|
|
var lastLineLen = summaryLines[^1].len
|
|
|
|
|
|
|
|
|
|
if issue.hasProp("delegated-to"):
|
|
|
|
|
if lastLineLen + issue["delegated-to"].len + 1 < summaryWidth:
|
|
|
|
|
result &= " " & issue["delegated-to"].withColor(fgMagenta)
|
|
|
|
|
lastLineLen += issue["delegated-to"].len + 1
|
|
|
|
|
else:
|
|
|
|
|
result &= "\p" & issue["delegated-to"]
|
|
|
|
|
.withColor(fgMagenta)
|
|
|
|
|
.indent(summaryIndentLen)
|
|
|
|
|
lastLineLen = issue["delegated-to"].len
|
|
|
|
|
|
|
|
|
|
if issue.tags.len > 0:
|
|
|
|
|
let tagsStr = "(" & issue.tags.join(", ") & ")"
|
|
|
|
|
if (result.splitLines[^1].len + tagsStr.len + 1) > (width - 2):
|
|
|
|
|
result &= "\n" & indent
|
|
|
|
|
result &= " " & tagsStr.withColor(fgGreen)
|
|
|
|
|
let tagsStrLines = ("(" & issue.tags.join(", ") & ")")
|
|
|
|
|
.wrapWords(summaryWidth)
|
|
|
|
|
.splitLines
|
|
|
|
|
|
|
|
|
|
if tagsStrLines.len == 1 and
|
|
|
|
|
(lastLineLen + tagsStrLines[0].len + 1) < summaryWidth:
|
|
|
|
|
result &= " " & tagsStrLines[0].withColor(fgGreen)
|
|
|
|
|
lastLineLen += tagsStrLines[0].len + 1
|
|
|
|
|
else:
|
|
|
|
|
result &= "\p" & tagsStrLines
|
|
|
|
|
.mapIt(it.indent(summaryIndentLen))
|
|
|
|
|
.join("\p")
|
|
|
|
|
.withColor(fgGreen)
|
|
|
|
|
lastLineLen = tagsStrLines[^1].len
|
|
|
|
|
|
|
|
|
|
if issue.hasProp("pending"):
|
|
|
|
|
let startIdx = "Pending: ".len
|
|
|
|
|
var pendingText = issue["pending"].wrapWords(width - startIdx - summaryIndentLen)
|
|
|
|
|
.indent(startIdx)
|
|
|
|
|
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(summaryIndentLen)
|
|
|
|
|
result &= "\n" & pendingText.withColor(fgCyan)
|
|
|
|
|
result &= "\p" & ("Pending: " & issue["pending"])
|
|
|
|
|
.wrapwords(summaryWidth)
|
|
|
|
|
.withColor(fgCyan)
|
|
|
|
|
.indent(summaryIndentLen)
|
|
|
|
|
|
|
|
|
|
if showDetails:
|
|
|
|
|
result &= "\n" & issue.details.strip.indent(indent.len + 2).withColor(fgCyan)
|
|
|
|
|
result &= "\p" & issue.details
|
|
|
|
|
.strip
|
|
|
|
|
.withColor(fgBlack, bright = true)
|
|
|
|
|
.indent(summaryIndentLen)
|
|
|
|
|
|
|
|
|
|
result &= termReset
|
|
|
|
|
|
|
|
|
@ -146,15 +167,14 @@ proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
|
|
|
|
|
else: result &= ctx.formatSectionIssueList(issues, innerWidth, indent, verbose)
|
|
|
|
|
|
|
|
|
|
proc loadIssues(ctx: CliContext, state: IssueState) =
|
|
|
|
|
ctx.issues[state] = loadIssues(ctx.tasksDir / $state)
|
|
|
|
|
ctx.issues[state] = loadIssues(ctx.cfg.tasksDir, state)
|
|
|
|
|
|
|
|
|
|
proc loadOpenIssues(ctx: CliContext) =
|
|
|
|
|
ctx.issues = newTable[IssueState, seq[Issue]]()
|
|
|
|
|
for state in [Current, TodoToday, Todo, Pending, Todo]: ctx.loadIssues(state)
|
|
|
|
|
|
|
|
|
|
proc loadAllIssues(ctx: CliContext) =
|
|
|
|
|
ctx.issues = newTable[IssueState, seq[Issue]]()
|
|
|
|
|
for state in IssueState: ctx.loadIssues(state)
|
|
|
|
|
ctx.issues = ctx.cfg.tasksDir.loadAllIssues()
|
|
|
|
|
|
|
|
|
|
proc filterIssues(ctx: CliContext, filter: IssueFilter) =
|
|
|
|
|
for state, issueList in ctx.issues:
|
|
|
|
@ -191,7 +211,7 @@ 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")
|
|
|
|
|
discard os.execShellCmd(EDITOR & " '" & (ctx.cfg.tasksDir / $state / "order.txt") & "' </dev/tty >/dev/tty")
|
|
|
|
|
|
|
|
|
|
proc edit(issue: Issue) =
|
|
|
|
|
|
|
|
|
@ -263,7 +283,11 @@ proc list(
|
|
|
|
|
if future:
|
|
|
|
|
if today: ctx.writeHeader("Future")
|
|
|
|
|
|
|
|
|
|
for s in [Pending, Todo]:
|
|
|
|
|
let futureCategories =
|
|
|
|
|
if showToday: @[Todo]
|
|
|
|
|
else: @[Pending, Todo]
|
|
|
|
|
|
|
|
|
|
for s in futureCategories:
|
|
|
|
|
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
|
|
|
|
|
let visibleIssues = ctx.issues[s].filterIt(
|
|
|
|
|
showHidden or
|
|
|
|
@ -291,7 +315,7 @@ when isMainModule:
|
|
|
|
|
if args["--echo-args"]: stderr.writeLine($args)
|
|
|
|
|
|
|
|
|
|
if args["help"]:
|
|
|
|
|
stderr.writeLine(USAGE & "\n")
|
|
|
|
|
stderr.writeLine(USAGE & "\p")
|
|
|
|
|
stderr.writeLine(ONLINE_HELP)
|
|
|
|
|
quit()
|
|
|
|
|
|
|
|
|
@ -302,6 +326,7 @@ when isMainModule:
|
|
|
|
|
var propertiesOption = none(TableRef[string,string])
|
|
|
|
|
var exclPropsOption = none(TableRef[string,seq[string]])
|
|
|
|
|
var tagsOption = none(seq[string])
|
|
|
|
|
var exclTagsOption = none(seq[string])
|
|
|
|
|
|
|
|
|
|
if args["--properties"] or args["--context"]:
|
|
|
|
|
|
|
|
|
@ -329,6 +354,9 @@ when isMainModule:
|
|
|
|
|
|
|
|
|
|
if args["--tags"]: tagsOption = some(($args["--tags"]).split(",").mapIt(it.strip))
|
|
|
|
|
|
|
|
|
|
if args["--excl-tags"]: exclTagsOption =
|
|
|
|
|
some(($args["--excl-tags"]).split(",").mapIt(it.strip))
|
|
|
|
|
|
|
|
|
|
## Actual command runners
|
|
|
|
|
if args["new"] or args["add"]:
|
|
|
|
|
let state =
|
|
|
|
@ -346,10 +374,10 @@ when isMainModule:
|
|
|
|
|
summary: $args["<summary>"],
|
|
|
|
|
properties: issueProps,
|
|
|
|
|
tags:
|
|
|
|
|
if args["--tags"]: ($args["--tags"]).split(",").mapIt(it.strip)
|
|
|
|
|
if tagsOption.isSome: tagsOption.get
|
|
|
|
|
else: newSeq[string]())
|
|
|
|
|
|
|
|
|
|
ctx.tasksDir.store(issue, state)
|
|
|
|
|
ctx.cfg.tasksDir.store(issue, state)
|
|
|
|
|
|
|
|
|
|
stdout.writeLine ctx.formatIssue(issue)
|
|
|
|
|
|
|
|
|
@ -369,25 +397,25 @@ when isMainModule:
|
|
|
|
|
ctx.loadIssues(state)
|
|
|
|
|
for issue in ctx.issues[state]: edit(issue)
|
|
|
|
|
|
|
|
|
|
else: edit(ctx.tasksDir.loadIssueById(editRef))
|
|
|
|
|
else: edit(ctx.cfg.tasksDir.loadIssueById(editRef))
|
|
|
|
|
|
|
|
|
|
elif args["tag"]:
|
|
|
|
|
if not args["--tags"]: raise newException(Exception, "no tags given")
|
|
|
|
|
if tagsOption.isNone: raise newException(Exception, "no tags given")
|
|
|
|
|
|
|
|
|
|
let newTags = ($args["--tags"]).split(",").mapIt(it.strip)
|
|
|
|
|
let newTags = tagsOption.get
|
|
|
|
|
|
|
|
|
|
for id in @(args["<id>"]):
|
|
|
|
|
var issue = ctx.tasksDir.loadIssueById(id)
|
|
|
|
|
var issue = ctx.cfg.tasksDir.loadIssueById(id)
|
|
|
|
|
issue.tags = deduplicate(issue.tags & newTags)
|
|
|
|
|
issue.store()
|
|
|
|
|
|
|
|
|
|
elif args["untag"]:
|
|
|
|
|
let tagsToRemove: seq[string] =
|
|
|
|
|
if args["--tags"]: ($args["--tags"]).split(",").mapIt(it.strip)
|
|
|
|
|
if tagsOption.isSome: tagsOption.get
|
|
|
|
|
else: @[]
|
|
|
|
|
|
|
|
|
|
for id in @(args["<id>"]):
|
|
|
|
|
var issue = ctx.tasksDir.loadIssueById(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))
|
|
|
|
@ -406,24 +434,24 @@ when isMainModule:
|
|
|
|
|
elif args["suspend"]: targetState = Dormant
|
|
|
|
|
|
|
|
|
|
for id in @(args["<id>"]):
|
|
|
|
|
var issue = ctx.tasksDir.loadIssueById(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.tasksDir.nextRecurrence(issue.getRecurrence.get, issue)
|
|
|
|
|
ctx.tasksDir.store(nextIssue, Todo)
|
|
|
|
|
let nextIssue = ctx.cfg.tasksDir.nextRecurrence(issue.getRecurrence.get, issue)
|
|
|
|
|
ctx.cfg.tasksDir.store(nextIssue, Todo)
|
|
|
|
|
info "created the next recurrence:"
|
|
|
|
|
stdout.writeLine ctx.formatIssue(nextIssue)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
issue.changeState(ctx.tasksDir, targetState)
|
|
|
|
|
issue.changeState(ctx.cfg.tasksDir, targetState)
|
|
|
|
|
|
|
|
|
|
if ctx.triggerPtk or args["--ptk"]:
|
|
|
|
|
if targetState == Current:
|
|
|
|
|
let issue = ctx.tasksDir.loadIssueById($(args["<id>"][0]))
|
|
|
|
|
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(
|
|
|
|
@ -440,14 +468,14 @@ when isMainModule:
|
|
|
|
|
|
|
|
|
|
elif args["hide-until"]:
|
|
|
|
|
|
|
|
|
|
let issue = ctx.tasksDir.loadIssueById($(args["<id>"]))
|
|
|
|
|
let issue = ctx.cfg.tasksDir.loadIssueById($(args["<id>"]))
|
|
|
|
|
issue.setDateTime("hide-until", parseDate($args["<date>"]))
|
|
|
|
|
|
|
|
|
|
issue.store()
|
|
|
|
|
|
|
|
|
|
elif args["delegate"]:
|
|
|
|
|
|
|
|
|
|
let issue = ctx.tasksDir.loadIssueById($(args["<id>"]))
|
|
|
|
|
let issue = ctx.cfg.tasksDir.loadIssueById($(args["<id>"]))
|
|
|
|
|
issue["delegated-to"] = $args["<delegated-to>"]
|
|
|
|
|
|
|
|
|
|
issue.store()
|
|
|
|
@ -455,7 +483,7 @@ when isMainModule:
|
|
|
|
|
elif args["delete"] or args["rm"]:
|
|
|
|
|
for id in @(args["<id>"]):
|
|
|
|
|
|
|
|
|
|
let issue = ctx.tasksDir.loadIssueById(id)
|
|
|
|
|
let issue = ctx.cfg.tasksDir.loadIssueById(id)
|
|
|
|
|
|
|
|
|
|
if not args["--yes"]:
|
|
|
|
|
stderr.write("Delete '" & issue.summary & "' (y/n)? ")
|
|
|
|
@ -494,8 +522,12 @@ when isMainModule:
|
|
|
|
|
filter.properties["context"] = ctx.defaultContext.get
|
|
|
|
|
filterOption = some(filter)
|
|
|
|
|
|
|
|
|
|
if args["--tags"]:
|
|
|
|
|
filter.hasTags = ($args["--tags"]).split(',')
|
|
|
|
|
if tagsOption.isSome:
|
|
|
|
|
filter.hasTags = tagsOption.get
|
|
|
|
|
filterOption = some(filter)
|
|
|
|
|
|
|
|
|
|
if exclTagsOption.isSome:
|
|
|
|
|
filter.exclTags = exclTagsOption.get
|
|
|
|
|
filterOption = some(filter)
|
|
|
|
|
|
|
|
|
|
# Finally, if the "context" is "all", don't filter on context
|
|
|
|
@ -534,7 +566,7 @@ when isMainModule:
|
|
|
|
|
# List a specific issue
|
|
|
|
|
elif issueIdsOption.isSome:
|
|
|
|
|
for issueId in issueIdsOption.get:
|
|
|
|
|
let issue = ctx.tasksDir.loadIssueById(issueId)
|
|
|
|
|
let issue = ctx.cfg.tasksDir.loadIssueById(issueId)
|
|
|
|
|
stdout.writeLine ctx.formatIssue(issue)
|
|
|
|
|
|
|
|
|
|
# List all issues
|
|
|
|
@ -550,7 +582,7 @@ when isMainModule:
|
|
|
|
|
verbose = ctx.verbose)
|
|
|
|
|
|
|
|
|
|
elif args["add-binary-property"]:
|
|
|
|
|
let issue = ctx.tasksDir.loadIssueById($(args["<id>"]))
|
|
|
|
|
let issue = ctx.cfg.tasksDir.loadIssueById($(args["<id>"]))
|
|
|
|
|
|
|
|
|
|
let propIn =
|
|
|
|
|
if $(args["<propSource>"]) == "-": stdin
|
|
|
|
@ -562,7 +594,7 @@ when isMainModule:
|
|
|
|
|
issue.store()
|
|
|
|
|
|
|
|
|
|
elif args["get-binary-property"]:
|
|
|
|
|
let issue = ctx.tasksDir.loadIssueById($(args["<id>"]))
|
|
|
|
|
let issue = ctx.cfg.tasksDir.loadIssueById($(args["<id>"]))
|
|
|
|
|
|
|
|
|
|
if not issue.hasProp($(args["<propName>"])):
|
|
|
|
|
raise newException(Exception,
|
|
|
|
|