Compare commits
No commits in common. "40cb602362857536e0e7308fda8f7186e83f47dd" and "e955cd5b2438822d3ddb1a175ec3756f8ac83a5b" have entirely different histories.
40cb602362
...
e955cd5b24
@ -1,6 +1,6 @@
|
||||
# Package
|
||||
|
||||
version = "4.28.0"
|
||||
version = "4.27.0"
|
||||
author = "Jonathan Bernard"
|
||||
description = "Personal issue tracker."
|
||||
license = "MIT"
|
||||
@ -28,4 +28,4 @@ requires @[
|
||||
]
|
||||
|
||||
task updateVersion, "Update the version of this package.":
|
||||
exec "update_nim_package_version pit 'src/pit/cliconstants.nim'"
|
||||
exec "update_nim_package_version pit 'src/pitpkg/cliconstants.nim'"
|
@ -1 +0,0 @@
|
||||
switch("define", "ssl")
|
303
src/pit.nim
303
src/pit.nim
@ -1,19 +1,205 @@
|
||||
## Personal Issue Tracker CLI interface
|
||||
## ====================================
|
||||
|
||||
import std/[algorithm, logging, options, os, sequtils, tables, times, unicode]
|
||||
import data_uri, docopt, json, timeutils, uuids
|
||||
import std/[algorithm, logging, options, os, sequtils, wordwrap, tables,
|
||||
terminal, times, unicode]
|
||||
import cliutils, 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]
|
||||
import pitpkg/private/libpit
|
||||
import pitpkg/cliconstants
|
||||
export libpit
|
||||
|
||||
export formatting, libpit
|
||||
type
|
||||
CliContext = ref object
|
||||
cfg*: PitConfig
|
||||
contexts*: TableRef[string, string]
|
||||
defaultContext*: Option[string]
|
||||
issues*: TableRef[IssueState, seq[Issue]]
|
||||
termWidth*: int
|
||||
triggerPtk*, verbose*: bool
|
||||
|
||||
let EDITOR =
|
||||
if existsEnv("EDITOR"): getEnv("EDITOR")
|
||||
else: "vi"
|
||||
|
||||
|
||||
proc initContext(args: Table[string, Value]): CliContext =
|
||||
let pitCfg = loadConfig(args)
|
||||
|
||||
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] =
|
||||
result = newTable[string, string]()
|
||||
for propText in propsOpt.split(";"):
|
||||
@ -31,6 +217,16 @@ proc parseExclPropertiesOption(propsOpt: string): TableRef[string, seq[string]]
|
||||
if result.hasKey(pair[0]): result[pair[0]].add(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) =
|
||||
|
||||
# load the issues to make sure the order file contains all issues in the state.
|
||||
@ -53,6 +249,96 @@ proc edit(issue: Issue) =
|
||||
getCurrentExceptionMsg()
|
||||
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:
|
||||
try:
|
||||
|
||||
@ -413,15 +699,6 @@ when isMainModule:
|
||||
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"])
|
||||
|
||||
except CatchableError:
|
||||
fatal getCurrentExceptionMsg()
|
||||
debug getCurrentException().getStackTrace()
|
||||
|
@ -1,249 +0,0 @@
|
||||
import std/[options, sequtils, wordwrap, tables, terminal, times, unicode, wordwrap]
|
||||
import cliutils, uuids
|
||||
import std/strutils except alignLeft, capitalize, strip, toLower, toUpper
|
||||
import ./libpit
|
||||
|
||||
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 = terminalWidth() - (indent.len * 2)
|
||||
|
||||
result = termColor(fgBlue) &
|
||||
(indent & ".".repeat(innerWidth)) & "\n" &
|
||||
state.displayName.center(terminalWidth()) & "\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(terminalWidth()))
|
||||
stdout.writeLine(header.center(terminalWidth()))
|
||||
stdout.writeLine('~'.repeat(terminalWidth()))
|
||||
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"
|
@ -1,176 +0,0 @@
|
||||
import std/[httpclient, json, jsonutils, logging, options, sets, strutils,
|
||||
terminal, times, tables]
|
||||
import timeutils, uuids, zero_functional
|
||||
import ./formatting, ./libpit
|
||||
|
||||
type
|
||||
PbmVsbSyncContext = object
|
||||
pit: PitConfig
|
||||
apiBaseUrl: string
|
||||
apiToken: string
|
||||
issueContext: string
|
||||
http: HttpClient
|
||||
|
||||
ServerTask* = object
|
||||
id*: string
|
||||
summary*: string
|
||||
details*: string
|
||||
state*: string
|
||||
lastUpdatedAt*: DateTime
|
||||
archivedAt*: Option[DateTime]
|
||||
tags*: seq[string]
|
||||
priority*: Option[string]
|
||||
project*: Option[string]
|
||||
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)
|
||||
func `%`*(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,
|
||||
lastUpdatedAt:
|
||||
if i.hasProp("last-updated"): parseIso8601(i["last-updated"])
|
||||
else: now(),
|
||||
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"
|
||||
|
||||
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 =
|
||||
|
||||
if not dryRun: echo "NOT DRY RUN"
|
||||
# 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
|
@ -10,8 +10,8 @@
|
||||
import asyncdispatch, cliutils, docopt, jester, json, logging, options, sequtils, strutils
|
||||
import nre except toSeq
|
||||
|
||||
import pit/libpit
|
||||
import pit/cliconstants
|
||||
import pitpkg/private/libpit
|
||||
import pitpkg/cliconstants
|
||||
|
||||
type
|
||||
PitApiCfg* = object
|
||||
|
@ -1,4 +1,4 @@
|
||||
const PIT_VERSION* = "4.28.0"
|
||||
const PIT_VERSION* = "4.27.0"
|
||||
|
||||
const USAGE* = """Usage:
|
||||
pit ( new | add) <summary> [<state>] [options]
|
||||
@ -16,7 +16,6 @@ const USAGE* = """Usage:
|
||||
pit add-binary-property <id> <propName> <propSource> [options]
|
||||
pit get-binary-property <id> <propName> <propDest> [options]
|
||||
pit show-dupes
|
||||
pit sync [<syncTarget>...] [options]
|
||||
pit help [options]
|
||||
|
||||
Options:
|
||||
@ -75,13 +74,11 @@ Options:
|
||||
-d, --tasks-dir Path to the tasks directory (defaults to the value
|
||||
configured in the .pitrc file)
|
||||
|
||||
--term-width <width> Manually set the terminal width to use.
|
||||
|
||||
--ptk Enable PTK integration for this command.
|
||||
|
||||
--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.
|
||||
"""
|
||||
|
||||
const ONLINE_HELP* = """Issue States:
|
||||
@ -189,4 +186,4 @@ Issue Properties:
|
||||
|
||||
If present, expected to be a comma-delimited list of text tags. The -g
|
||||
option is a short-hand for '-p tags:<tags-value>'.
|
||||
"""
|
||||
"""
|
@ -33,22 +33,13 @@ type
|
||||
PitConfig* = ref object
|
||||
tasksDir*: string
|
||||
contexts*: TableRef[string, string]
|
||||
syncTargets*: seq[JsonNode]
|
||||
cfg*: CombinedConfig
|
||||
|
||||
CliContext* = ref object
|
||||
cfg*: PitConfig
|
||||
contexts*: TableRef[string, string]
|
||||
defaultContext*: Option[string]
|
||||
issues*: TableRef[IssueState, seq[Issue]]
|
||||
triggerPtk*, verbose*: bool
|
||||
|
||||
Recurrence* = object
|
||||
cloneId*: Option[string]
|
||||
interval*: TimeInterval
|
||||
isFromCompletion*: bool
|
||||
|
||||
|
||||
const DONE_FOLDER_FORMAT* = "yyyy-MM"
|
||||
const ISO8601_MS = "yyyy-MM-dd'T'HH:mm:ss'.'fffzzz"
|
||||
|
||||
@ -77,11 +68,6 @@ proc displayName*(s: IssueState): string =
|
||||
of Todo: 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
|
||||
proc `[]`*(issue: Issue, key: string): string =
|
||||
return issue.properties[key]
|
||||
@ -89,7 +75,6 @@ proc `[]`*(issue: Issue, key: string): string =
|
||||
proc `[]=`*(issue: Issue, key: string, value: string) =
|
||||
issue.properties[key] = value
|
||||
|
||||
|
||||
## Issue property accessors
|
||||
proc hasProp*(issue: Issue, key: string): bool =
|
||||
return issue.properties.hasKey(key)
|
||||
@ -128,7 +113,6 @@ proc getRecurrence*(issue: Issue): Option[Recurrence] =
|
||||
else: weeks(1),
|
||||
cloneId: c[6]))
|
||||
|
||||
|
||||
## Issue filtering
|
||||
proc initFilter*(): IssueFilter =
|
||||
result = IssueFilter(
|
||||
@ -189,7 +173,6 @@ proc parseDate*(d: string): DateTime =
|
||||
continue
|
||||
raise newException(ValueError, "Unable to parse input as a date: " & d & errMsg)
|
||||
|
||||
|
||||
## Parse and format issues
|
||||
proc fromStorageFormat*(id: string, issueTxt: string): Issue =
|
||||
type ParseState = enum ReadingSummary, ReadingProps, ReadingDetails
|
||||
@ -255,7 +238,6 @@ proc toStorageFormat*(issue: Issue, withComments = false): string =
|
||||
|
||||
result = lines.join("\n")
|
||||
|
||||
|
||||
## Load and store from filesystem
|
||||
proc loadIssue*(filePath: string): Issue =
|
||||
result = fromStorageFormat(splitFile(filePath).name, readFile(filePath))
|
||||
@ -391,7 +373,6 @@ proc nextRecurrence*(tasksDir: string, rec: Recurrence, defaultIssue: Issue): Is
|
||||
|
||||
result.setDateTime("hide-until", nextTime)
|
||||
|
||||
|
||||
## Utilities for working with issue collections.
|
||||
proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
|
||||
var f: seq[Issue] = issues
|
||||
@ -435,7 +416,6 @@ proc find*(
|
||||
result = @[]
|
||||
for stateIssues in issues.values: result &= stateIssues.filter(filter)
|
||||
|
||||
|
||||
### Configuration utilities
|
||||
proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitConfig =
|
||||
var pitrcFilename: string
|
||||
@ -460,8 +440,7 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
|
||||
result = PitConfig(
|
||||
cfg: cfg,
|
||||
contexts: newTable[string,string](),
|
||||
tasksDir: cfg.getVal("tasks-dir", ""),
|
||||
syncTargets: cfg.getJson("sync-targets", newJArray()).getElems)
|
||||
tasksDir: cfg.getVal("tasks-dir", ""))
|
||||
|
||||
for k, v in cfg.getJson("contexts", newJObject()):
|
||||
result.contexts[k] = v.getStr()
|
||||
@ -476,38 +455,3 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
|
||||
for s in IssueState:
|
||||
if not dirExists(result.tasksDir / $s):
|
||||
(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)
|
Loading…
x
Reference in New Issue
Block a user