Compare commits

..

15 Commits

Author SHA1 Message Date
6ceca9b009 Make add interactive and allow defining default properties that should be prompted. 2025-11-24 10:47:22 -06:00
de07665a8b Project boards: only show contexts with selected issues. 2025-11-19 19:00:01 -06:00
5dd7a15bf4 Add support for a project view like virtual-status-board.probatem.com 2025-11-19 17:12:14 -06:00
6ac068fe75 Move getIssueContextDisplayName to libpit (out of formatting module). 2025-11-19 17:11:21 -06:00
759d00e2f8 Move filter logic earlier in CLI processing, support state filtering. 2025-11-19 17:10:14 -06:00
bc37640f2e Bump Nim version to 2.2.6. 2025-11-19 17:08:01 -06:00
3ee5bdf8fd Support for syncing all properties (including context) to Probatem's Virtual Status Board. 2025-08-02 22:44:04 -05:00
85d561c8a5 Update Nim pinned version, drop jester dependency (pit_api currently not supported). 2025-05-06 14:45:08 -05:00
1064de3e1b Add support for syncing the parent property (if it exists). 2025-01-13 08:16:07 -06:00
8b0c751344 Bump version for 4.29.0 release. 2025-01-11 11:59:52 -06:00
0f7e257f76 Cap output width to 80 characters max. 2025-01-11 11:59:17 -06:00
76225d1c50 Add the autoSync config property to automatically sync contexts after relevant issues change. 2025-01-11 11:58:47 -06:00
0361d1b869 Add the --silent option to allow suppressing log output. 2025-01-11 11:58:02 -06:00
40cb602362 Add support for syncing to Probatem's Virtual Status Board. 2025-01-09 11:57:32 -06:00
e2a306c1d6 Refactor to clean up package source structure. 2025-01-08 18:07:19 -06:00
10 changed files with 1183 additions and 383 deletions

View File

@@ -1,2 +1,2 @@
[tools] [tools]
nim = "2.2.0" nim = "2.2.6"

View File

@@ -1,6 +1,6 @@
# Package # Package
version = "4.27.0" version = "4.31.0"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Personal issue tracker." description = "Personal issue tracker."
license = "MIT" license = "MIT"
@@ -13,14 +13,13 @@ bin = @["pit"]
requires @[ requires @[
"nim >= 1.4.0", "nim >= 1.4.0",
"docopt >= 0.7.1", "docopt >= 0.7.1",
"jester >= 0.6.0",
"uuids >= 0.1.10", "uuids >= 0.1.10",
"zero_functional" "zero_functional"
] ]
# Dependencies from git.jdb-software.com/jdb/nim-packages # Dependencies from git.jdb-software.com/jdb/nim-packages
requires @[ requires @[
"cliutils >= 0.9.1", "cliutils >= 0.10.2",
"langutils >= 0.4.0", "langutils >= 0.4.0",
"timeutils >= 0.5.4", "timeutils >= 0.5.4",
"data_uri > 1.0.0", "data_uri > 1.0.0",
@@ -28,4 +27,4 @@ requires @[
] ]
task updateVersion, "Update the version of this package.": task updateVersion, "Update the version of this package.":
exec "update_nim_package_version pit 'src/pitpkg/cliconstants.nim'" exec "update_nim_package_version pit 'src/pit/cliconstants.nim'"

1
src/config.nims Normal file
View File

@@ -0,0 +1 @@
switch("define", "ssl")

View File

@@ -1,205 +1,20 @@
## Personal Issue Tracker CLI interface ## Personal Issue Tracker CLI interface
## ==================================== ## ====================================
import std/[algorithm, logging, options, os, sequtils, wordwrap, tables, import std/[algorithm, logging, options, os, sequtils, sets, tables, terminal,
terminal, times, unicode] times, unicode]
import cliutils, data_uri, docopt, json, timeutils, uuids import cliutils, data_uri, docopt, json, timeutils, uuids, zero_functional
from nre import re from nre import match, re
import strutils except alignLeft, capitalize, strip, toUpper, toLower import strutils except alignLeft, capitalize, strip, toUpper, toLower
import pitpkg/private/libpit import pit/[cliconstants, formatting, libpit, projects, sync_pbm_vsb]
import pitpkg/cliconstants
export libpit
type export formatting, libpit
CliContext = ref object
cfg*: PitConfig
contexts*: TableRef[string, string]
defaultContext*: Option[string]
issues*: TableRef[IssueState, seq[Issue]]
termWidth*: int
triggerPtk*, verbose*: bool
let EDITOR = let EDITOR =
if existsEnv("EDITOR"): getEnv("EDITOR") if existsEnv("EDITOR"): getEnv("EDITOR")
else: "vi" else: "vi"
proc initContext(args: Table[string, Value]): CliContext =
let pitCfg = loadConfig(args)
let cliJson =
if pitCfg.cfg.json.hasKey("cli"): pitCfg.cfg.json["cli"]
else: newJObject()
let cliCfg = CombinedConfig(docopt: args, json: cliJson)
result = CliContext(
cfg: pitCfg,
contexts: pitCfg.contexts,
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]](),
termWidth: parseInt(cliCfg.getVal("termWidth", "80")),
triggerPtk: cliJson.getOrDefault("triggerPtk").getBool(false))
proc getIssueContextDisplayName(ctx: CliContext, context: string): string =
if not ctx.contexts.hasKey(context):
if context.isEmptyOrWhitespace: return "<default>"
else: return context.capitalize()
return ctx.contexts[context]
proc formatIssue*(issue: Issue): string =
result = ($issue.id).withColor(fgBlack, true) & "\n"&
issue.summary.withColor(fgWhite) & "\n"
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.isEmptyOrWhitespace:
result &= issue.details.strip.withColor(fgCyan) & "\n"
result &= termReset
proc formatPlainIssueSummary*(issue: Issue): string =
result = "$#: $# $#" % [
$issue.state,
($issue.id)[0..<6],
issue.summary ]
if issue.hasProp("delegated-to") or issue.hasProp("pending"):
var parts = newSeq[string]()
if issue.hasProp("delegated-to"):
parts.add("delegated to " & issue["delegated-to"])
if issue.hasProp("pending"):
parts.add("pendin: " & issue["pending"])
result &= "($#)" % [ parts.join("; ") ]
proc formatSectionIssue*(
issue: Issue,
width: int = 80,
indent = "",
verbose = false): string =
result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true) & " "
let showDetails = not issue.details.isEmptyOrWhitespace and verbose
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 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"):
result &= "\p" & ("Pending: " & issue["pending"])
.wrapwords(summaryWidth)
.withColor(fgCyan)
.indent(summaryIndentLen)
if showDetails:
result &= "\p" & issue.details
.strip
.withColor(fgBlack, bright = true)
.indent(summaryIndentLen)
result &= termReset
proc formatSectionIssueList*(
issues: seq[Issue],
width: int = 80,
indent: string = "",
verbose: bool = false): string =
result = ""
for i in issues:
var issueText = formatSectionIssue(i, width, indent, verbose)
result &= issueText & "\n"
proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
indent = "", verbose = false): string =
let innerWidth = ctx.termWidth - (indent.len * 2)
result = termColor(fgBlue) &
(indent & ".".repeat(innerWidth)) & "\n" &
state.displayName.center(ctx.termWidth) & "\n\n" &
termReset
let issuesByContext = issues.groupBy("context")
if issues.len > 5 and issuesByContext.len > 1:
for context, ctxIssues in issuesByContext:
result &= termColor(fgYellow) &
indent & ctx.getIssueContextDisplayName(context) & ":" &
termReset & "\n\n"
result &= formatSectionIssueList(ctxIssues, innerWidth - 2, indent & " ", verbose)
result &= "\n"
else: result &= formatSectionIssueList(issues, innerWidth, indent, verbose)
proc loadIssues(ctx: CliContext, state: IssueState) =
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 = ctx.cfg.tasksDir.loadAllIssues()
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] = proc parsePropertiesOption(propsOpt: string): TableRef[string, string] =
result = newTable[string, string]() result = newTable[string, string]()
for propText in propsOpt.split(";"): for propText in propsOpt.split(";"):
@@ -217,15 +32,6 @@ proc parseExclPropertiesOption(propsOpt: string): TableRef[string, seq[string]]
if result.hasKey(pair[0]): result[pair[0]].add(val) if result.hasKey(pair[0]): result[pair[0]].add(val)
else: result[pair[0]] = @[val] else: result[pair[0]] = @[val]
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 reorder(ctx: CliContext, state: IssueState) = proc reorder(ctx: CliContext, state: IssueState) =
@@ -233,6 +39,88 @@ proc reorder(ctx: CliContext, state: IssueState) =
ctx.loadIssues(state) ctx.loadIssues(state)
discard os.execShellCmd(EDITOR & " '" & (ctx.cfg.tasksDir / $state / "order.txt") & "' </dev/tty >/dev/tty") discard os.execShellCmd(EDITOR & " '" & (ctx.cfg.tasksDir / $state / "order.txt") & "' </dev/tty >/dev/tty")
proc addIssue(
ctx: CliContext,
args: Table[string, Value],
propertiesOption = none[TableRef[string, string]](),
tagsOption = none[seq[string]]()): Issue =
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
if not args["--non-interactive"]:
# look for default properties for this context
let globalDefaultProps =
if ctx.cfg.defaultPropertiesByContext.hasKey("<all>"):
ctx.cfg.defaultPropertiesByContext["<all>"]
else: newSeq[string]()
let contextDefaultProps =
if issueProps.hasKey("context") and
ctx.cfg.defaultPropertiesByContext.hasKey(issueProps["context"]):
ctx.cfg.defaultPropertiesByContext[issueProps["context"]]
else: newSeq[string]()
let defaultProps = toOrderedSet(globalDefaultProps & contextDefaultProps)
if defaultProps.len > 0:
ctx.loadAllIssues()
if issueProps.hasKey("context"):
ctx.filterIssues(propsFilter(newTable({"context": issueProps["context"]})))
let numberRegex = re("^[0-9]+$")
for propName in defaultProps:
if not issueProps.hasKey(propName):
let allIssues: seq[seq[Issue]] = toSeq(values(ctx.issues))
let previousValues = toSeq(toHashSet(allIssues -->
flatten()
.filter(it.hasProp(propName))
.map(it[propName])))
let idxValPairs: seq[tuple[key: int, val: string]] = toSeq(pairs(previousValues))
let previousValuesDisplay: seq[string] = idxValPairs -->
map(" " & $it[0] & " - " & it[1])
stdout.write(
"Previous values for property '" & propName & "':\p" &
previousValuesDisplay.join("\p") & "\p" &
"Do you want to set a value for '" & propName & "'? " &
"You can use the numbers above to use an existing value, enter " &
"something new, or leave blank to indicate no value.\p" &
withColor(propName, fgMagenta) & ":" &
withColor(" ", fgBlue, bright=true, skipReset=true))
let resp = stdin.readLine.strip
let numberResp = resp.match(numberRegex)
if numberResp.isSome:
let idx = parseInt(resp)
if idx >= 0 and idx < previousValues.len:
issueProps[propName] = previousValues[idx]
elif resp.len > 0:
issueProps[propName] = resp
stdout.writeLine(termReset)
result = Issue(
id: genUUID(),
summary: $args["<summary>"],
properties: issueProps,
tags:
if tagsOption.isSome: tagsOption.get
else: newSeq[string]())
ctx.cfg.tasksDir.store(result, state)
stdout.writeLine "\p" & formatIssue(result)
proc edit(issue: Issue) = proc edit(issue: Issue) =
# Write format comments (to help when editing) # Write format comments (to help when editing)
@@ -249,96 +137,6 @@ proc edit(issue: Issue) =
getCurrentExceptionMsg() getCurrentExceptionMsg()
issue.store() issue.store()
proc list(
ctx: CliContext,
filter: Option[IssueFilter],
states: Option[seq[IssueState]],
showToday = false,
showFuture = false,
showHidden = false,
verbose: bool) =
if states.isSome:
trace "listing issues for " & $states.get
for state in states.get:
ctx.loadIssues(state)
if filter.isSome: ctx.filterIssues(filter.get)
# Show Done for just today if requested
if state == Done and showToday:
ctx.issues[Done] = ctx.issues[Done].filterIt(
it.hasProp("completed") and
sameDay(getTime().local, it.getDateTime("completed")))
if isatty(stdout):
stdout.write ctx.formatSection(ctx.issues[state], state, "", verbose)
else:
stdout.writeLine ctx.issues[state]
.mapIt(formatPlainIssueSummary(it))
.join("\n")
trace "listing complete"
return
ctx.loadOpenIssues()
if filter.isSome:
ctx.filterIssues(filter.get)
trace "filtered issues"
let today = showToday and [Current, TodoToday, Pending].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
if today:
if future: ctx.writeHeader("Today")
for s in [Current, TodoToday, Pending]:
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
let visibleIssues = ctx.issues[s].filterIt(
showHidden or
not (it.hasProp("hide-until") and
it.getDateTime("hide-until") > getTime().local))
if isatty(stdout):
stdout.write ctx.formatSection(visibleIssues, s, indent, verbose)
else:
stdout.writeLine visibleIssues
.mapIt(formatPlainIssueSummary(it))
.join("\n")
# Future items
if future:
if today: ctx.writeHeader("Future")
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
not (it.hasProp("hide-until") and
it.getDateTime("hide-until") > getTime().local))
if isatty(stdout):
stdout.write ctx.formatSection(visibleIssues, s, indent, verbose)
else:
stdout.writeLine visibleIssues
.mapIt(formatPlainIssueSummary(it))
.join("\n")
trace "listing complete"
when isMainModule: when isMainModule:
try: try:
@@ -353,6 +151,9 @@ when isMainModule:
if args["--debug"]: if args["--debug"]:
consoleLogger.levelThreshold = lvlDebug consoleLogger.levelThreshold = lvlDebug
if args["--silent"]:
consoleLogger.levelThreshold = lvlNone
if args["--echo-args"]: stderr.writeLine($args) if args["--echo-args"]: stderr.writeLine($args)
if args["help"]: if args["help"]:
@@ -364,11 +165,16 @@ when isMainModule:
trace "context initiated" trace "context initiated"
var updatedIssues = newSeq[Issue]()
var propertiesOption = none(TableRef[string,string]) var propertiesOption = none(TableRef[string,string])
var exclPropsOption = none(TableRef[string,seq[string]]) var exclPropsOption = none(TableRef[string,seq[string]])
var tagsOption = none(seq[string]) var tagsOption = none(seq[string])
var exclTagsOption = none(seq[string]) var exclTagsOption = none(seq[string])
let filter = initFilter()
var filterOption = none(IssueFilter)
if args["--properties"] or args["--context"]: if args["--properties"] or args["--context"]:
var props = var props =
@@ -398,29 +204,57 @@ when isMainModule:
if args["--excl-tags"]: exclTagsOption = if args["--excl-tags"]: exclTagsOption =
some(($args["--excl-tags"]).split(",").mapIt(it.strip)) 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 ## Actual command runners
if args["new"] or args["add"]: if args["new"] or args["add"]:
let state = updatedIssues.add(ctx.addIssue(args, propertiesOption, tagsOption))
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)
stdout.writeLine formatIssue(issue)
elif args["reorder"]: elif args["reorder"]:
ctx.reorder(parseEnum[IssueState]($args["<state>"])) ctx.reorder(parseEnum[IssueState]($args["<state>"]))
@@ -446,6 +280,7 @@ when isMainModule:
for k,v in propsOption.get: for k,v in propsOption.get:
issue[k] = v issue[k] = v
edit(issue) edit(issue)
updatedIssues.add(issue)
else: else:
let issue = ctx.cfg.tasksDir.loadIssueById(editRef) let issue = ctx.cfg.tasksDir.loadIssueById(editRef)
@@ -453,6 +288,7 @@ when isMainModule:
for k,v in propertiesOption.get: for k,v in propertiesOption.get:
issue[k] = v issue[k] = v
edit(issue) edit(issue)
updatedIssues.add(issue)
elif args["tag"]: elif args["tag"]:
if tagsOption.isNone: raise newException(Exception, "no tags given") if tagsOption.isNone: raise newException(Exception, "no tags given")
@@ -463,6 +299,7 @@ when isMainModule:
var issue = ctx.cfg.tasksDir.loadIssueById(id) var issue = ctx.cfg.tasksDir.loadIssueById(id)
issue.tags = deduplicate(issue.tags & newTags) issue.tags = deduplicate(issue.tags & newTags)
issue.store() issue.store()
updatedIssues.add(issue)
elif args["untag"]: elif args["untag"]:
let tagsToRemove: seq[string] = let tagsToRemove: seq[string] =
@@ -476,6 +313,7 @@ when isMainModule:
proc (tag: string): bool = not tagsToRemove.anyIt(it == tag)) proc (tag: string): bool = not tagsToRemove.anyIt(it == tag))
else: issue.tags = @[] else: issue.tags = @[]
issue.store() issue.store()
updatedIssues.add(issue)
elif args["start"] or args["todo-today"] or args["done"] or elif args["start"] or args["todo-today"] or args["done"] or
args["pending"] or args["todo"] or args["suspend"]: args["pending"] or args["todo"] or args["suspend"]:
@@ -499,10 +337,11 @@ when isMainModule:
let nextIssue = ctx.cfg.tasksDir.nextRecurrence(issue.getRecurrence.get, issue) let nextIssue = ctx.cfg.tasksDir.nextRecurrence(issue.getRecurrence.get, issue)
ctx.cfg.tasksDir.store(nextIssue, TodoToday) ctx.cfg.tasksDir.store(nextIssue, TodoToday)
info "created the next recurrence:" info "created the next recurrence:"
updatedIssues.add(nextIssue)
stdout.writeLine formatIssue(nextIssue) stdout.writeLine formatIssue(nextIssue)
issue.changeState(ctx.cfg.tasksDir, targetState) issue.changeState(ctx.cfg.tasksDir, targetState)
updatedIssues.add(issue)
if ctx.triggerPtk or args["--ptk"]: if ctx.triggerPtk or args["--ptk"]:
if targetState == Current: if targetState == Current:
@@ -527,6 +366,7 @@ when isMainModule:
issue.setDateTime("hide-until", parseDate($args["<date>"])) issue.setDateTime("hide-until", parseDate($args["<date>"]))
issue.store() issue.store()
updatedIssues.add(issue)
elif args["delegate"]: elif args["delegate"]:
@@ -534,70 +374,33 @@ when isMainModule:
issue["delegated-to"] = $args["<delegated-to>"] issue["delegated-to"] = $args["<delegated-to>"]
issue.store() issue.store()
updatedIssues.add(issue)
elif args["delete"] or args["rm"]: elif args["delete"] or args["rm"]:
for id in @(args["<id>"]): for id in @(args["<id>"]):
let issue = ctx.cfg.tasksDir.loadIssueById(id) let issue = ctx.cfg.tasksDir.loadIssueById(id)
if not args["--yes"]: if not args["--non-interactive"]:
stderr.write("Delete '" & issue.summary & "' (y/n)? ") stderr.write("Delete '" & issue.summary & "' (y/n)? ")
if not "yes".startsWith(stdin.readLine.toLower): if not "yes".startsWith(stdin.readLine.toLower):
continue continue
issue.delete issue.delete
updatedIssues.add(issue)
elif args["list"]: elif args["list"]:
let filter = initFilter()
var filterOption = none(IssueFilter)
# 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)
# 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")
var listContexts = false var listContexts = false
var listTags = false var listTags = false
var listProjects = false
var listMilestones = false
var statesOption = none(seq[IssueState]) var statesOption = none(seq[IssueState])
var issueIdsOption = none(seq[string]) var issueIdsOption = none(seq[string])
if args["contexts"]: listContexts = true if args["contexts"]: listContexts = true
elif args["projects"]: listProjects = true
elif args["milestones"]: listMilestones = true
elif args["tags"]: listTags = true elif args["tags"]: listTags = true
elif args["<stateOrId>"]: elif args["<stateOrId>"]:
try: try:
@@ -645,6 +448,12 @@ when isMainModule:
let issue = ctx.cfg.tasksDir.loadIssueById(issueId) let issue = ctx.cfg.tasksDir.loadIssueById(issueId)
stdout.writeLine formatIssue(issue) stdout.writeLine formatIssue(issue)
# List projects
elif listProjects: ctx.listProjects(filterOption)
# List milestones
elif listMilestones: ctx.listMilestones(filterOption)
# List all issues # List all issues
else: else:
trace "listing all issues" trace "listing all issues"
@@ -657,6 +466,32 @@ when isMainModule:
showHidden = args["--show-hidden"], showHidden = args["--show-hidden"],
verbose = ctx.verbose) verbose = ctx.verbose)
elif args["show"]:
if args["project-board"]:
ctx.showProjectBoard(filterOption)
discard
elif args["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")
else: # list specific Issues
for issueId in args["<id>"].mapIt($it):
let issue = ctx.cfg.tasksDir.loadIssueById(issueId)
stdout.writeLine formatIssue(issue)
elif args["add-binary-property"]: elif args["add-binary-property"]:
let issue = ctx.cfg.tasksDir.loadIssueById($(args["<id>"])) let issue = ctx.cfg.tasksDir.loadIssueById($(args["<id>"]))
@@ -668,6 +503,7 @@ when isMainModule:
finally: close(propIn) finally: close(propIn)
issue.store() issue.store()
updatedIssues.add(issue)
elif args["get-binary-property"]: elif args["get-binary-property"]:
let issue = ctx.cfg.tasksDir.loadIssueById($(args["<id>"])) let issue = ctx.cfg.tasksDir.loadIssueById($(args["<id>"]))
@@ -684,20 +520,23 @@ when isMainModule:
try: write(propOut, decodeDataUri(issue[$(args["<propName>"])])) try: write(propOut, decodeDataUri(issue[$(args["<propName>"])]))
finally: close(propOut) finally: close(propOut)
elif args["show-dupes"]: elif args["sync"]:
ctx.loadAllIssues() if ctx.cfg.syncTargets.len == 0:
info "No sync targets configured"
var idsToPaths = newTable[string, var seq[string]]() for syncTarget in ctx.cfg.syncTargets:
for (state, issues) in pairs(ctx.issues): let syncCtx = initSyncContext(ctx.cfg, syncTarget)
for issue in issues:
let issueId = $issue.id
if idsToPaths.hasKey(issueId): idsToPaths[issueId].add(issue.filepath) sync(syncCtx, args["--dry-run"])
else: idsToPaths[issueId] = @[issue.filepath]
for (issueId, issuePaths) in pairs(idsToPaths): # after doing stuff, sync if auto-sync is requested
if issuePaths.len < 2: continue if ctx.cfg.autoSync:
stdout.writeLine(issueId & ":\p " & issuePaths.join("\p ") & "\p\p") 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: except CatchableError:
fatal getCurrentExceptionMsg() fatal getCurrentExceptionMsg()

View File

@@ -1,8 +1,10 @@
const PIT_VERSION* = "4.27.0" const PIT_VERSION* = "4.31.0"
const USAGE* = """Usage: const USAGE* = """Usage:
pit ( new | add) <summary> [<state>] [options] pit ( new | add) <summary> [<state>] [options]
pit list contexts [options] pit list contexts [options]
pit list projects [options]
pit list milestones [options]
pit list tags [options] pit list tags [options]
pit list [<stateOrId>...] [options] pit list [<stateOrId>...] [options]
pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options] pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options]
@@ -15,9 +17,13 @@ const USAGE* = """Usage:
pit ( delete | rm ) <id>... [options] pit ( delete | rm ) <id>... [options]
pit add-binary-property <id> <propName> <propSource> [options] pit add-binary-property <id> <propName> <propSource> [options]
pit get-binary-property <id> <propName> <propDest> [options] pit get-binary-property <id> <propName> <propDest> [options]
pit show-dupes pit show dupes [options]
pit show project-board [options]
pit show <id> [options]
pit sync [<syncTarget>...] [options]
pit help [options] pit help [options]
Options: Options:
-h, --help Print this usage and help information. -h, --help Print this usage and help information.
@@ -65,8 +71,6 @@ Options:
-q, --quiet Suppress verbose output. -q, --quiet Suppress verbose output.
-y, --yes Automatically answer "yes" to any prompts.
--config <cfgFile> Location of the config file (defaults to $HOME/.pitrc) --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).
@@ -74,11 +78,21 @@ Options:
-d, --tasks-dir Path to the tasks directory (defaults to the value -d, --tasks-dir Path to the tasks directory (defaults to the value
configured in the .pitrc file) configured in the .pitrc file)
--term-width <width> Manually set the terminal width to use.
--ptk Enable PTK integration for this command. --ptk Enable PTK integration for this command.
--debug Enable debug-level log output. --debug Enable debug-level log output.
--dry-run Currently only supported by the `sync` command:
only print the changes that would be made, but do
not actually make them.
-I, --non-interactive Run in non-interactive mode. Commands that would
normally prompt for user input will instead use
default values or fail if required input is not
provided via command-line options.
-s, --silent Suppress all logging and status output and run in
non-interactive mode (implies --non-interactive).
""" """
const ONLINE_HELP* = """Issue States: const ONLINE_HELP* = """Issue States:
@@ -139,6 +153,38 @@ Issue Properties:
hide-until: 2024-01-01T13:45:00-05:00 hide-until: 2024-01-01T13:45:00-05:00
priority
Allows setting a priority/priority level. This is used in the project
management view to automatically order issues being displayed. Valid
values, in order from most to least important, are:
- essential Intended for issues that must be done. Failure to
complete these issues would result in failure of the
project.
- vital Intended for issues that are vital to the success of the
project, but not absolutely essential. Failure to complete
these issues may not result in failure of the project but
would seriously impact it's value or viability.
- important Intended for issues that are important to the project,
but not vital. These should be completed, but delay is
acceptable.
- optional Intended for issues that are worth doing but can be deferred
or skipped if necessary.
priority: essential
milestone
Allows grouping issues according to a milestone name. Milestones are
available as subsets of projects. The 'list milestones' command will show
all values of 'milestone' set in existing issues for a given project.
milestone: Phase 1
pending pending
When an issue is blocked by a third party, this property can be used to When an issue is blocked by a third party, this property can be used to
@@ -147,6 +193,13 @@ Issue Properties:
pending: Results of WCAG analysis. pending: Results of WCAG analysis.
project
Allows grouping issues according to a project name. The 'list projects'
command will show all values of 'project' set in existing issues.
project: Website Redesign
recurrence recurrence
When an issue is moved to the "done" state, if the issue has a valid When an issue is moved to the "done" state, if the issue has a valid

258
src/pit/formatting.nim Normal file
View File

@@ -0,0 +1,258 @@
import std/[options, sequtils, tables, terminal, times, unicode, wordwrap]
import cliutils, uuids
import std/strutils except alignLeft, capitalize, strip, toLower, toUpper
import ./libpit
proc adjustedTerminalWidth(): int = min(terminalWidth(), 80)
proc formatIssue*(issue: Issue): string =
result = ($issue.id).withColor(fgBlack, true) & "\n"&
issue.summary.withColor(fgWhite) & "\n"
if issue.tags.len > 0:
result &= "tags: ".withColor(fgMagenta) &
issue.tags.join(",").withColor(fgGreen, true) & "\n"
if issue.properties.len > 0:
for k, v in issue.properties:
if k == "project":
result &= "project: ".withColor(fgMagenta) &
v.withColor(fgBlue, bright = true) & "\n"
elif k == "milestone":
result &= "milestone: ".withColor(fgMagenta) &
v.withColor(fgBlue, bright = true) & "\n"
elif k == "priority":
result &= "priority: ".withColor(fgMagenta) &
v.withColor(fgRed, bright = true) & "\n"
else:
result &= termColor(fgMagenta) & k & ": " & v & "\n"
result &= "--------".withColor(fgBlack, true) & "\n"
if not issue.details.isEmptyOrWhitespace:
result &= issue.details.strip.withColor(fgCyan) & "\n"
result &= termReset
proc formatPlainIssueSummary*(issue: Issue): string =
result = "$#: $# $#" % [
$issue.state,
($issue.id)[0..<6],
issue.summary ]
if issue.hasProp("delegated-to") or issue.hasProp("pending"):
var parts = newSeq[string]()
if issue.hasProp("delegated-to"):
parts.add("delegated to " & issue["delegated-to"])
if issue.hasProp("pending"):
parts.add("pendin: " & issue["pending"])
result &= "($#)" % [ parts.join("; ") ]
proc formatSectionIssue*(
issue: Issue,
width: int = 80,
indent = "",
verbose = false,
bold = false): string =
result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true) & " "
let showDetails = not issue.details.isEmptyOrWhitespace and verbose
let summaryIndentLen = indent.len + 7
let summaryWidth = width - summaryIndentLen
let summaryLines = issue.summary
.wrapWords(summaryWidth)
.splitLines
result &= summaryLines[0].termFmt(fgWhite, bold=bold, underline=bold)
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 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"):
result &= "\p" & ("Pending: " & issue["pending"])
.wrapwords(summaryWidth)
.withColor(fgCyan)
.indent(summaryIndentLen)
if showDetails:
result &= "\p" & issue.details
.strip
.withColor(fgBlack, bright = true)
.indent(summaryIndentLen)
result &= termReset
proc formatSectionIssueList*(
issues: seq[Issue],
width: int = 80,
indent: string = "",
verbose: bool = false): string =
result = ""
for i in issues:
var issueText = formatSectionIssue(i, width, indent, verbose)
result &= issueText & "\n"
proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
indent = "", verbose = false): string =
let innerWidth = adjustedTerminalWidth() - (indent.len * 2)
result = termColor(fgBlue) &
(indent & ".".repeat(innerWidth)) & "\n" &
state.displayName.center(adjustedTerminalWidth()) & "\n\n" &
termReset
let issuesByContext = issues.groupBy("context")
if issues.len > 5 and issuesByContext.len > 1:
for context, ctxIssues in issuesByContext:
result &= termColor(fgYellow) &
indent & ctx.getIssueContextDisplayName(context) & ":" &
termReset & "\n\n"
result &= formatSectionIssueList(ctxIssues, innerWidth - 2, indent & " ", verbose)
result &= "\n"
else: result &= formatSectionIssueList(issues, innerWidth, indent, verbose)
proc writeHeader*(ctx: CliContext, header: string) =
stdout.setForegroundColor(fgRed, true)
stdout.writeLine('_'.repeat(adjustedTerminalWidth()))
stdout.writeLine(header.center(adjustedTerminalWidth()))
stdout.writeLine('~'.repeat(adjustedTerminalWidth()))
stdout.resetAttributes
proc list*(
ctx: CliContext,
filter: Option[IssueFilter],
states: Option[seq[IssueState]],
showToday = false,
showFuture = false,
showHidden = false,
verbose: bool) =
if states.isSome:
trace "listing issues for " & $states.get
for state in states.get:
ctx.loadIssues(state)
if filter.isSome: ctx.filterIssues(filter.get)
# Show Done for just today if requested
if state == Done and showToday:
ctx.issues[Done] = ctx.issues[Done].filterIt(
it.hasProp("completed") and
sameDay(getTime().local, it.getDateTime("completed")))
if isatty(stdout):
stdout.write ctx.formatSection(ctx.issues[state], state, "", verbose)
else:
stdout.writeLine ctx.issues[state]
.mapIt(formatPlainIssueSummary(it))
.join("\n")
trace "listing complete"
return
ctx.loadOpenIssues()
if filter.isSome:
ctx.filterIssues(filter.get)
trace "filtered issues"
let today = showToday and [Current, TodoToday, Pending].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
if today:
if future: ctx.writeHeader("Today")
for s in [Current, TodoToday, Pending]:
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
let visibleIssues = ctx.issues[s].filterIt(
showHidden or
not (it.hasProp("hide-until") and
it.getDateTime("hide-until") > getTime().local))
if isatty(stdout):
stdout.write ctx.formatSection(visibleIssues, s, indent, verbose)
else:
stdout.writeLine visibleIssues
.mapIt(formatPlainIssueSummary(it))
.join("\n")
# Future items
if future:
if today: ctx.writeHeader("Future")
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
not (it.hasProp("hide-until") and
it.getDateTime("hide-until") > getTime().local))
if isatty(stdout):
stdout.write ctx.formatSection(visibleIssues, s, indent, verbose)
else:
stdout.writeLine visibleIssues
.mapIt(formatPlainIssueSummary(it))
.join("\n")
trace "listing complete"

View File

@@ -1,4 +1,5 @@
import std/[json, logging, options, os, strformat, strutils, tables, times] import std/[json, jsonutils, logging, options, os, strformat, strutils, tables, times,
unicode]
import cliutils, docopt, langutils, uuids, zero_functional import cliutils, docopt, langutils, uuids, zero_functional
import nre except toSeq import nre except toSeq
@@ -18,28 +19,43 @@ type
Current = "current", Current = "current",
TodoToday = "todo-today", TodoToday = "todo-today",
Pending = "pending", Pending = "pending",
Done = "done",
Todo = "todo" Todo = "todo"
Dormant = "dormant" Dormant = "dormant"
Done = "done",
IssueFilter* = ref object IssueFilter* = ref object
completedRange*: Option[tuple[b, e: DateTime]] completedRange*: Option[tuple[b, e: DateTime]]
fullMatch*, summaryMatch*: Option[Regex] fullMatch*, summaryMatch*: Option[Regex]
inclStates*: seq[IssueState]
exclStates*: seq[IssueState]
hasTags*: seq[string] hasTags*: seq[string]
exclTags*: seq[string] exclTags*: seq[string]
properties*: TableRef[string, string] properties*: TableRef[string, string]
exclProperties*: TableRef[string, seq[string]] exclProperties*: TableRef[string, seq[string]]
IssuePriority* {.pure.} = enum essential, vital, important, optional
PitConfig* = ref object PitConfig* = ref object
tasksDir*: string tasksDir*: string
contexts*: TableRef[string, string] contexts*: TableRef[string, string]
defaultPropertiesByContext*: TableRef[string, seq[string]]
autoSync*: bool
syncTargets*: seq[JsonNode]
cfg*: CombinedConfig cfg*: CombinedConfig
CliContext* = ref object
cfg*: PitConfig
contexts*: TableRef[string, string]
defaultContext*: Option[string]
issues*: TableRef[IssueState, seq[Issue]]
triggerPtk*, verbose*: bool
Recurrence* = object Recurrence* = object
cloneId*: Option[string] cloneId*: Option[string]
interval*: TimeInterval interval*: TimeInterval
isFromCompletion*: bool isFromCompletion*: bool
const DONE_FOLDER_FORMAT* = "yyyy-MM" const DONE_FOLDER_FORMAT* = "yyyy-MM"
const ISO8601_MS = "yyyy-MM-dd'T'HH:mm:ss'.'fffzzz" const ISO8601_MS = "yyyy-MM-dd'T'HH:mm:ss'.'fffzzz"
@@ -68,6 +84,11 @@ proc displayName*(s: IssueState): string =
of Todo: result = "Todo" of Todo: result = "Todo"
of TodoToday: result = "Todo" of TodoToday: result = "Todo"
proc sameDay*(a, b: DateTime): bool =
result = a.year == b.year and a.yearday == b.yearday
## Allow issue properties to be accessed as if the issue was a table ## Allow issue properties to be accessed as if the issue was a table
proc `[]`*(issue: Issue, key: string): string = proc `[]`*(issue: Issue, key: string): string =
return issue.properties[key] return issue.properties[key]
@@ -75,6 +96,7 @@ 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
## Issue property accessors ## Issue property accessors
proc hasProp*(issue: Issue, key: string): bool = proc hasProp*(issue: Issue, key: string): bool =
return issue.properties.hasKey(key) return issue.properties.hasKey(key)
@@ -113,12 +135,22 @@ proc getRecurrence*(issue: Issue): Option[Recurrence] =
else: weeks(1), else: weeks(1),
cloneId: c[6])) cloneId: c[6]))
proc setPriority*(issue: Issue, priority: IssuePriority) =
issue["priority"] = $priority
proc getPriority*(issue: Issue): IssuePriority =
try: result = parseEnum[IssuePriority](issue["priority"].toLowerAscii())
except CatchableError: result = IssuePriority.optional
## Issue filtering ## Issue filtering
proc initFilter*(): IssueFilter = proc initFilter*(): IssueFilter =
result = IssueFilter( result = IssueFilter(
completedRange: none(tuple[b, e: DateTime]), completedRange: none(tuple[b, e: DateTime]),
fullMatch: none(Regex), fullMatch: none(Regex),
summaryMatch: none(Regex), summaryMatch: none(Regex),
inclStates: @[],
exclStates: @[],
hasTags: @[], hasTags: @[],
exclTags: @[], exclTags: @[],
properties: newTable[string, string](), properties: newTable[string, string](),
@@ -148,6 +180,10 @@ proc hasTagsFilter*(tags: seq[string]): IssueFilter =
result = initFilter() result = initFilter()
result.hasTags = tags result.hasTags = tags
proc stateFilter*(states: seq[IssueState]): IssueFilter =
result = initFilter()
result.inclStates = states
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:
@@ -173,6 +209,7 @@ proc parseDate*(d: string): DateTime =
continue continue
raise newException(ValueError, "Unable to parse input as a date: " & d & errMsg) raise newException(ValueError, "Unable to parse input as a date: " & d & errMsg)
## Parse and format issues ## Parse and format issues
proc fromStorageFormat*(id: string, issueTxt: string): Issue = proc fromStorageFormat*(id: string, issueTxt: string): Issue =
type ParseState = enum ReadingSummary, ReadingProps, ReadingDetails type ParseState = enum ReadingSummary, ReadingProps, ReadingDetails
@@ -238,6 +275,7 @@ proc toStorageFormat*(issue: Issue, withComments = false): string =
result = lines.join("\n") result = lines.join("\n")
## Load and store from filesystem ## Load and store from filesystem
proc loadIssue*(filePath: string): Issue = proc loadIssue*(filePath: string): Issue =
result = fromStorageFormat(splitFile(filePath).name, readFile(filePath)) result = fromStorageFormat(splitFile(filePath).name, readFile(filePath))
@@ -373,6 +411,7 @@ proc nextRecurrence*(tasksDir: string, rec: Recurrence, defaultIssue: Issue): Is
result.setDateTime("hide-until", nextTime) result.setDateTime("hide-until", nextTime)
## Utilities for working with issue collections. ## Utilities for working with issue collections.
proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] = proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
var f: seq[Issue] = issues var f: seq[Issue] = issues
@@ -406,6 +445,12 @@ proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
let exclTag = exclTagLent let exclTag = exclTagLent
f = f --> filter(it.tags.find(exclTag) < 0) f = f --> filter(it.tags.find(exclTag) < 0)
if filter.inclStates.len > 0:
f = f --> filter(filter.inclStates.contains(it.state))
if filter.exclStates.len > 0:
f = f --> filter(not filter.exclStates.contains(it.state))
return f # not using result because zero_functional doesn't play nice with it return f # not using result because zero_functional doesn't play nice with it
proc find*( proc find*(
@@ -416,6 +461,7 @@ proc find*(
result = @[] result = @[]
for stateIssues in issues.values: result &= stateIssues.filter(filter) for stateIssues in issues.values: result &= stateIssues.filter(filter)
### Configuration utilities ### Configuration utilities
proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitConfig = proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitConfig =
var pitrcFilename: string var pitrcFilename: string
@@ -439,12 +485,20 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
result = PitConfig( result = PitConfig(
cfg: cfg, cfg: cfg,
autoSync: parseBool(cfg.getVal("auto-sync", "false")),
contexts: newTable[string,string](), contexts: newTable[string,string](),
tasksDir: cfg.getVal("tasks-dir", "")) defaultPropertiesByContext: newTable[string, seq[string]](),
tasksDir: cfg.getVal("tasks-dir", ""),
syncTargets: cfg.getJson("sync-targets", newJArray()).getElems)
for k, v in cfg.getJson("contexts", newJObject()): for k, v in cfg.getJson("contexts", newJObject()):
result.contexts[k] = v.getStr() result.contexts[k] = v.getStr()
for k, v in cfg.getJson("defaultPropertiesByContext", newJObject()):
result.defaultPropertiesByContext[k] = v.getElems() -->
map(it.getStr("").strip())
.filter(not it.isEmptyOrWhitespace)
if isEmptyOrWhitespace(result.tasksDir): if isEmptyOrWhitespace(result.tasksDir):
raise newException(Exception, "no tasks directory configured") raise newException(Exception, "no tasks directory configured")
@@ -455,3 +509,44 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
for s in IssueState: for s in IssueState:
if not dirExists(result.tasksDir / $s): if not dirExists(result.tasksDir / $s):
(result.tasksDir / $s).createDir (result.tasksDir / $s).createDir
## CliContext functionality
proc initContext*(args: Table[string, Value]): CliContext =
let pitCfg = loadConfig(args)
let cliJson =
if pitCfg.cfg.json.hasKey("cli"): pitCfg.cfg.json["cli"]
else: newJObject()
let cliCfg = CombinedConfig(docopt: args, json: cliJson)
result = CliContext(
cfg: pitCfg,
contexts: pitCfg.contexts,
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]](),
triggerPtk: cliJson.getOrDefault("triggerPtk").getBool(false))
proc loadIssues*(ctx: CliContext, state: IssueState) =
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 = ctx.cfg.tasksDir.loadAllIssues()
proc filterIssues*(ctx: CliContext, filter: IssueFilter) =
for state, issueList in ctx.issues:
ctx.issues[state] = issueList.filter(filter)
proc getIssueContextDisplayName*(ctx: CliContext, context: string): string =
if not ctx.contexts.hasKey(context):
if context.isEmptyOrWhitespace: return "<default>"
else: return context.capitalize()
return ctx.contexts[context]

368
src/pit/projects.nim Normal file
View File

@@ -0,0 +1,368 @@
import std/[algorithm, json, jsonutils, options, os, sets, strutils, tables,
terminal, times, unicode, wordwrap]
from std/sequtils import repeat, toSeq
import cliutils, uuids, zero_functional
import ./[formatting, libpit]
type
ProjectCfg* = ref object of RootObj
name: string
milestoneOrder*: seq[string]
Project* = ref object of ProjectCfg
milestones*: TableRef[string, seq[Issue]]
ProjectsConfiguration* = TableRef[string, seq[ProjectCfg]]
## ProjectCfgs by context
ProjectsDatabase* = TableRef[string, seq[Project]]
## Projects by context
converter extractConfig(pdb: ProjectsDatabase): ProjectsConfiguration =
result = newTable[string, seq[ProjectCfg]]()
for (context, projects) in pairs(pdb):
result[context] = @[]
for project in projects:
result[context].add(ProjectCfg(
name: project.name,
milestoneOrder: project.milestoneOrder))
proc loadProjectsConfiguration*(ctx: CliContext): ProjectsConfiguration =
let projectsCfgFile = ctx.cfg.tasksDir & "/projects.json"
if not fileExists(projectsCfgFile):
return newTable[string, seq[ProjectCfg]]()
else:
fromJson[ProjectsConfiguration](result, parseFile(projectsCfgFile))
proc saveProjectsConfiguration*(ctx: CliContext, cfg: ProjectsConfiguration) =
let projectsCfgFile = ctx.cfg.tasksDir / "projects.json"
writeFile(projectsCfgFile, toJson(cfg).pretty)
proc buildDb*(ctx: CliContext, cfg: ProjectsConfiguration): ProjectsDatabase =
result = newTable[string, seq[Project]]()
# Expand the configuration into the database structure
for (context, projectCfgs) in pairs(cfg):
result[context] = @[]
for projectCfg in projectCfgs:
let pcfg = projectCfg
let project = Project(
name: projectCfg.name,
milestoneOrder: projectCfg.milestoneOrder,
milestones: newTable[string, seq[Issue]](
pcfg.milestoneOrder --> map((it, newSeq[Issue]()))))
result[context].add(project)
# Now populate the database with issues
for (state, issues) in pairs(ctx.issues):
for issue in issues:
if not issue.hasProp("project") or
not issue.hasProp("milestone"):
continue
let projectName = issue["project"]
let milestone = issue["milestone"]
let context =
if issue.hasProp("context"): issue["context"]
else: "<no-context>"
# Make sure we have entries for this context and project
if not result.hasKey(context): result[context] = @[]
var projectsInContext = result[context]
if not (projectsInContext --> exists(it.name == projectName)):
projectsInContext.add(Project(
name: projectName,
milestoneOrder: @[],
milestones: newTable[string, seq[Issue]]()))
let projectIdx = projectsInContext --> index(it.name == projectName)
var project = projectsInContext[projectIdx]
# Make sure we have entries for this milestone
if not project.milestones.hasKey(milestone):
project.milestones[milestone] = @[]
if not project.milestoneOrder.contains(milestone):
project.milestoneOrder.add(milestone)
project.milestones[milestone].add(issue)
result[context] = projectsInContext
ctx.saveProjectsConfiguration(result)
proc cmp*(a, b: Issue): int =
if a.state != b.state:
return cmp(ord(a.state), ord(b.state))
if a.hasProp("priority") or b.hasProp("priority"):
if a.getPriority != b.getPriority:
return cmp(a.getPriority, b.getPriority) # higher priority first
if a.hasProp("last-updated") or b.hasProp("last-updated"):
var aUpdated = a.getDateTime("last-updated", local(fromUnix(0)))
var bUpdated = b.getDateTime("last-updated", local(fromUnix(0)))
if aUpdated != bUpdated:
return cmp(bUpdated, aUpdated) # newer first
return cmp(a.summary, b.summary)
proc listProjects*(ctx: CliContext, filter = none[IssueFilter]()) =
ctx.loadAllIssues()
let projectsCfg = ctx.loadProjectsConfiguration()
var projectsCfgChanged = false
if filter.isSome: ctx.filterIssues(filter.get)
let projectsByContext = newTable[string, CountTableRef[string]]()
for (state, issues) in pairs(ctx.issues):
for issue in issues:
if issue.hasProp("project"):
let context =
if issue.hasProp("context"): issue["context"]
else: "<no-context>"
if not projectsByContext.hasKey(context):
projectsByContext[context] = newCountTable[string]()
projectsByContext[context].inc(issue["project"])
for (context, projects) in pairs(projectsByContext):
stdout.writeLine(withColor(
ctx.getIssueContextDisplayName(context) & ":",
fgYellow) & termReset)
stdout.writeLine("")
var toList = toHashSet(toSeq(keys(projects)))
# Loop through the projects in the configured order first
if not projectsCfg.hasKey(context):
projectsCfg[context] = @[]
for project in projectsCfg[context]:
if project.name in toList:
toList.excl(project.name)
stdout.writeLine(" " & project.name &
" (" & $projects[project.name] & " issues)")
# Then list any remaining projects not in the configuration, and add them
# to the configuration
for (projectName, count) in pairs(projects):
if projectName in toList:
stdout.writeLine(" " & projectName & " (" & $count & " issues)")
projectsCfg[context].add(ProjectCfg(name: projectName, milestoneOrder: @[]))
projectsCfgChanged = true
stdout.writeLine("")
if projectsCfgChanged: ctx.saveProjectsConfiguration(projectsCfg)
proc listMilestones*(ctx: CliContext, filter = none[IssueFilter]()) =
ctx.loadAllIssues()
if filter.isSome: ctx.filterIssues(filter.get)
let projectsCfg = ctx.loadProjectsConfiguration()
let projectsDb = ctx.buildDb(projectsCfg)
var milestones = newCountTable[string]()
for (context, projects) in pairs(projectsDb):
for p in projects:
let project = p
if values(project.milestones) --> all(it.len == 0):
continue
stdout.writeLine(withColor(project.name, fgBlue, bold = true, bright=true))
stdout.writeLine(withColor(
"".repeat(runeLen(stripAnsi(project.name))),
fgBlue, bold = true))
for milestone in project.milestoneOrder:
if project.milestones.hasKey(milestone) and
project.milestones[milestone].len > 0:
let issueCount = project.milestones[milestone].len
stdout.writeLine(" " & milestone & " (" & $issueCount & " issues)")
stdout.writeLine("")
proc formatProjectIssue(
ctx: CliContext,
issue: Issue,
width: int): seq[string] =
var firstLine = ""
if issue.state == IssueState.Done:
firstLine &= withColor("", fgBlack, bold=true, bright=true)
else:
case issue.getPriority
of IssuePriority.essential:
firstLine &= withColor("", fgRed, bold=true, bright=true)
of IssuePriority.vital:
firstLine &= withColor("", fgYellow, bold=true, bright=true)
of IssuePriority.important:
firstLine &= withColor("", fgBlue, bold=true, bright=true)
of IssuePriority.optional:
firstLine &= withColor("", fgBlack, bold=false, bright=true)
let summaryText = formatSectionIssue(issue, width - 3,
bold = [Current, TodoToday].contains(issue.state)).splitLines
firstLine &= summaryText[0]
if issue.state == IssueState.Done:
firstLine = withColor(stripAnsi(firstLine), fgBlack, bright=true)
result.add(firstLine)
result.add(summaryText[1 .. ^1] --> map(" " & it))
if issue.state == IssueState.Done:
let origLines = result
result = origLines --> map(withColor(stripAnsi(it), fgBlack, bright=true))
proc formatParentIssue*(
ctx: CliContext,
parentIssue: Issue,
children: seq[Issue],
width: int): seq[string] =
result.add(ctx.formatProjectIssue(parentIssue, width))
for child in sorted(children, cmp):
let childLines = ctx.formatProjectIssue(child, width - 3)
result.add(childLines --> map(withColor("", fgBlack, bright=true) & it))
result.add("")
proc formatMilestone*(
ctx: CliContext,
milestone: string,
issues: seq[Issue],
availWidth: int): seq[string] =
result = @[""]
result.add(withColor(milestone, fgWhite, bold=true))
result.add(withColor("".repeat(availWidth), fgWhite))
var parentsToChildren = issues -->
filter(it.hasProp("parent")).group(it["parent"])
var issuesToFormat = sorted(issues, cmp) -->
filter(not it.hasProp("parent"))
for issue in issuesToFormat:
if parentsToChildren.hasKey($issue.id):
result.add(
ctx.formatParentIssue(issue, parentsToChildren[$issue.id], availWidth))
else:
result.add(ctx.formatProjectIssue(issue, availWidth))
proc findShortestColumn(columns: seq[seq[string]]): int =
var shortestIdx = 0
var shortestLen = columns[0].len
for i in 1 ..< columns.len:
if columns[i].len < shortestLen:
shortestLen = columns[i].len
shortestIdx = i
return shortestIdx
proc joinColumns(columns: seq[seq[string]], columnWidth: int): seq[string] =
let maxLines = columns --> map(it.len).max()
for lineNo in 0 ..< maxLines:
var newLine = ""
for col in columns:
if lineNo < col.len:
let lineLen = runeLen(stripAnsi(col[lineNo]))
newLine &= col[lineNo] & " ".repeat(max(0, columnWidth - lineLen) + 2)
else:
newLine &= " ".repeat(columnWidth + 2)
result.add(newLine)
proc showProject*(ctx: CliContext, project: Project) =
let fullWidth = terminalWidth() - 1
let columnWidth = 80
let numColumns = (fullWidth - 4) div (columnWidth + 2)
stdout.writeLine("")
stdout.writeLine(withColor(
"" & "".repeat(project.name.len + 2) &
"" & "".repeat(fullWidth - project.name.len - 4) & "",
fgBlue, bold=true))
stdout.writeLine(
withColor("", fgBlue, bold=true) &
withColor(project.name, fgBlue, bold=true, bright=true) &
withColor("" & " ".repeat(fullWidth - project.name.len - 4) & "", fgBlue, bold=true))
stdout.writeLine(withColor(
"" & "".repeat(project.name.len + 2) &
"" & " ".repeat(fullWidth - project.name.len - 4) & "",
fgBlue, bold=true))
let milestoneTexts: seq[seq[string]] = project.milestoneOrder -->
filter(project.milestones.hasKey(it) and project.milestones[it].len > 0).
map(ctx.formatMilestone(it, project.milestones[it], columnWidth))
var columns: seq[seq[string]] = repeat(newSeq[string](), numColumns)
for milestoneText in milestoneTexts:
let shortestColumnIdx = findShortestColumn(columns)
columns[shortestColumnIdx].add(milestoneText)
let joinedLines = joinColumns(columns, columnWidth)
for line in joinedLines:
let padLen = fullWidth - runeLen(stripAnsi(line)) - 3
stdout.writeLine(
withColor("", fgBlue) &
line &
" ".repeat(padLen) &
withColor("", fgBlue))
stdout.writeLine(withColor(
"" & "".repeat(terminalWidth() - 2) & "",
fgBlue, bold=true))
proc showProjectBoard*(ctx: CliContext, filter = none[IssueFilter]()) =
ctx.loadAllIssues()
if filter.isSome: ctx.filterIssues(filter.get)
let projectsCfg = ctx.loadProjectsConfiguration()
let projectsDb = ctx.buildDb(projectsCfg)
var contextsAndProjects: seq[(string, seq[Project])] = @[]
for (context, pjs) in pairs(projectsDb):
let projects = pjs
let issues: seq[Issue] = projects --> map(toSeq(values(it.milestones))).flatten().flatten()
if issues.len > 0:
contextsAndProjects.add((context, projects))
for (context, projects) in contextsAndProjects:
if contextsAndProjects.len > 1:
stdout.writeLine("")
stdout.writeLine(withColor(
ctx.getIssueContextDisplayName(context) & ":",
fgYellow, bold=true))
stdout.writeLine("")
for p in projects:
let project = p
if (values(project.milestones) --> exists(it.len > 0)):
ctx.showProject(project)

187
src/pit/sync_pbm_vsb.nim Normal file
View File

@@ -0,0 +1,187 @@
import std/[httpclient, json, jsonutils, logging, options, sets, strutils,
terminal, times, tables]
import timeutils, uuids, zero_functional
import ./formatting, ./libpit
type
PbmVsbSyncContext* = object
apiBaseUrl*: string
apiToken*: string
issueContext*: string
pit: PitConfig
http: HttpClient
ServerTask* = object
id*: string
summary*: string
details*: string
state*: string
lastUpdatedAt*: DateTime
archivedAt*: Option[DateTime]
tags*: seq[string]
context*: Option[string]
parent*: Option[string]
priority*: Option[string]
project*: Option[string]
properties*: JsonNode
milestone*: Option[string]
hideUntil*: Option[DateTime]
proc `%`*(dt: DateTime): JsonNode = %(dt.formatIso8601)
func toJsonHook(dt: DateTime): JsonNode = %(dt.formatIso8601)
proc fromJsonHook(dt: var DateTime, n: JsonNode): void =
dt = n.getStr.parseIso8601
#func `%`*(p: Project): JsonNode = toJson(p)
proc `%`*(t: ServerTask): JsonNode = toJson(t)
proc toServerTask(i: Issue): ServerTask =
return ServerTask(
id: $i.id,
summary: i.summary,
details: i.details,
state: $i.state,
tags: i.tags,
properties: %i.properties,
context:
if i.hasProp("context"): some(i["context"])
else: none[string](),
lastUpdatedAt:
if i.hasProp("last-updated"): parseIso8601(i["last-updated"])
else: now(),
parent:
if i.hasProp("parent"): some(i["parent"])
else: none[string](),
priority:
if i.hasProp("priority"): some(i["priority"])
else: none[string](),
project:
if i.hasProp("project"): some(i["project"])
else: none[string](),
milestone:
if i.hasProp("milestone"): some(i["milestone"])
else: none[string](),
hideUntil:
if i.hasProp("hide-until"): some(parseIso8601(i["hide-until"]))
else: none[DateTime]())
func getOrFail(n: JsonNode, k: string): JsonNode =
if not n.hasKey(k):
raise newException(ValueError, "missing key '" & k & "'")
return n[k]
proc initSyncContext*(pit: PitConfig, syncConfig: JsonNode): PbmVsbSyncContext =
result.pit = pit
result.apiBaseUrl = syncConfig.getOrFail("apiBaseUrl").getStr
result.apiToken = syncConfig.getOrFail("apiToken").getStr
result.issueContext = syncConfig.getOrFail("context").getStr
result.http = newHttpClient()
result.http.headers = newHttpHeaders({ "Authorization": "Bearer " & result.apiToken })
proc fetchServerTasks*(ctx: PbmVsbSyncContext): seq[ServerTask] =
result = newSeq[ServerTask]()
let url = ctx.apiBaseUrl & "/tasks?context=" & ctx.issueContext
debug "Fetching tasks from server:\n\t" & url
let resp = ctx.http.get(url)
if resp.status != "200":
debug("Received " & resp.status & ": " & resp.body)
raise newException(Exception, "Failed to fetch tasks from server")
fromJson(result, parseJson(resp.body)["data"])
proc updateTask*(ctx: PbmVsbSyncContext, task: ServerTask): void =
let url = ctx.apiBaseUrl & "/task" & task.id
let body = %task
let resp = ctx.http.post(url, $body)
if resp.status != "200":
debug("Received " & resp.status & ": " & resp.body)
raise newException(Exception, "Failed to update task " & task.id & "on server")
proc updateTasks*(ctx: PbmVsbSyncContext, tasks: seq[ServerTask]): void =
let url = ctx.apiBaseUrl & "/tasks"
let body = %tasks
let resp = ctx.http.put(url, $body)
if resp.status != "200":
debug("Received " & resp.status & ": " & resp.body)
raise newException(Exception, "Failed to update tasks on server")
proc deleteTask*(ctx: PbmVsbSyncContext, taskId: string): void =
let url = ctx.apiBaseUrl & "/tasks/" & taskId
let resp = ctx.http.delete(url)
if resp.status != "200":
debug("Received " & resp.status & ": " & resp.body)
raise newException(Exception, "Failed to delete task " & taskId & " on server")
proc sync*(
ctx: PbmVsbSyncContext,
dryRun = true,
batchSize = 100): void =
# We're going to do a uni-directional sync, pushing local issues to the
# server. However, we only want to update issues that have changed since
# the last sync based on the *last-updated* property.
# Note that all the logic assumes the server only has one context of tasks.
# If this ever changes, this logic will need to change.
let filter = propsFilter(newTable({ "context": ctx.issueContext }))
let allIssues = ctx.pit.tasksDir.loadAllIssues()
var issues = newSeq[Issue]()
for state in allIssues.keys:
issues.add(allIssues[state].filter(filter))
let serverTasks = fetchServerTasks(ctx)
debug "Loaded $# server tasks" % [$serverTasks.len]
var issuesToPush = newSeq[ServerTask]()
var unmatchedServerTaskIds = toHashSet(serverTasks --> map(it.id))
# Process all local issues
info "Updating the following tasks for context " & ctx.issueContext
for lentIssue in issues:
let i = lentIssue
let foundTask = serverTasks --> find(it.id == $i.id)
if foundTask.isSome:
# There is a server task for this issue
unmatchedServerTaskIds.excl(foundTask.get.id)
if i.hasProp("last-updated"):
var localUpdate = parseIso8601(i["last-updated"])
localUpdate.nanosecond = 0
if foundTask.get.lastUpdatedAt >= localUpdate:
# but we don't have any update
continue
# We fell through the conditional block above, so either there isn't a
# server task, or we *do* have an update,
issuesToPush.add(toServerTask(i))
info " " & formatSectionIssue(i, width = terminalWidth() - 8)
# Now archive the issues on the server that we didn't see locally.
info "Archiving the following tasks for context " & ctx.issueContext
for task in serverTasks:
var toArchive = task
if unmatchedServerTaskIds.contains(task.id) and
toArchive.archivedAt.isNone:
toArchive.archivedAt = some(now())
issuesToPush.add(toArchive)
if not dryRun:
var offset = 0
while (offset < issuesToPush.len):
let batchSize = min(issuesToPush.len - offset, batchSize)
updateTasks(ctx, issuesToPush[offset ..< offset + batchSize])
offset += batchSize

View File

@@ -10,8 +10,8 @@
import asyncdispatch, cliutils, docopt, jester, json, logging, options, sequtils, strutils import asyncdispatch, cliutils, docopt, jester, json, logging, options, sequtils, strutils
import nre except toSeq import nre except toSeq
import pitpkg/private/libpit import pit/libpit
import pitpkg/cliconstants import pit/cliconstants
type type
PitApiCfg* = object PitApiCfg* = object