Compare commits

...

17 Commits
4.24.0 ... main

Author SHA1 Message Date
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
e955cd5b24 Add list tags sub-command, accept properties update to the edit command. 2025-01-06 10:38:47 -06:00
4176dfea3a Migrate from asdf to mise. 2024-12-10 10:00:53 -06:00
587e3c4509 Update for Nim 2.x. No longer building pit_api.
`pit_api` cannot be built for Nim 2.x without a non-trivial refactor due
to the enforcement of gc-safety. See the `pit_api.nim` source for
details.
2024-11-30 08:08:40 -06:00
f6a97c384a Use update_nim_package_version from the package repository. 2024-11-30 07:56:55 -06:00
0c3d73dc2b Add debug logging when changing the state of an issue. 2024-08-12 10:52:51 -05:00
9a0bf35882 Add asdf tool-versions definitions pinning this to Nim 1.6 2024-05-10 11:58:35 -05:00
be7c099b7b Format listed issues plainly when STDOUT is not a TTY 2024-01-01 12:55:01 -06:00
d04797460c Format listed issues plainly when STDIN is not a TTY
When calling pit from other programs or as part of a pipe, the display
style typically used to format listed issues contains a lot of unwanted
output (ANSI escape code, headings, etc.). Now when STDIN is not a TTY,
a plain and consistently formatted version of the issues is listed
without any additional formatting, one issue per line of output.
2024-01-01 12:47:43 -06:00
8cf0bf5d98 Change how time entries based on PIT issues are logged when using --ptk. 2023-12-17 07:32:46 -06:00
ddad90ddef Add examples to the online help for all special properties. 2023-12-15 21:34:38 -06:00
10 changed files with 613 additions and 451 deletions

2
.mise.toml Normal file
View File

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

View File

@ -1,174 +0,0 @@
{
"version": 2,
"packages": {
"asynctools": {
"version": "0.1.1",
"vcsRevision": "0e6bdc3ed5bae8c7cc9e03cfbf66b7c882a908a7",
"url": "https://github.com/cheatfate/asynctools",
"downloadMethod": "git",
"dependencies": [],
"checksums": {
"sha1": "54314dceabb06b20908ecb0f2c007e9ff3aaa054"
}
},
"isaac": {
"version": "0.1.3",
"vcsRevision": "45a5cbbd54ff59ba3ed94242620c818b9aad1b5b",
"url": "https://github.com/pragmagic/isaac/",
"downloadMethod": "git",
"dependencies": [],
"checksums": {
"sha1": "05c3583a954715d84b0bf1be97f9a503249e9cdf"
}
},
"uuids": {
"version": "0.1.11",
"vcsRevision": "8cb8720b567c6bcb261bd1c0f7491bdb5209ad06",
"url": "https://github.com/pragmagic/uuids/",
"downloadMethod": "git",
"dependencies": [
"isaac"
],
"checksums": {
"sha1": "393f5fcefbc8ad3cf167e59760144208ff8f9f76"
}
},
"unicodedb": {
"version": "0.11.2",
"vcsRevision": "c70f8bc8c7373265670e0575bc5eda36fe3761b0",
"url": "https://github.com/nitely/nim-unicodedb",
"downloadMethod": "git",
"dependencies": [],
"checksums": {
"sha1": "612c5955f91bd90a263ce914d1d5de74a33af3c6"
}
},
"regex": {
"version": "0.20.1",
"vcsRevision": "66f144f935cc73977c61185fab15a3147bf117ff",
"url": "https://github.com/nitely/nim-regex",
"downloadMethod": "git",
"dependencies": [
"unicodedb"
],
"checksums": {
"sha1": "ea9b6600443e73b1ea89a477c7a5d1fce742c9da"
}
},
"docopt": {
"version": "0.7.0",
"vcsRevision": "17803d1205f9e752cce03a66b0a29b710520398e",
"url": "https://github.com/docopt/docopt.nim",
"downloadMethod": "git",
"dependencies": [
"regex"
],
"checksums": {
"sha1": "21150284640b882fa91147181c52da3e5bb44df8"
}
},
"filetype": {
"version": "0.9.0",
"vcsRevision": "1fe1e7d988cd802abc26505efb5a91891bd6f53e",
"url": "https://github.com/jiro4989/filetype",
"downloadMethod": "git",
"dependencies": [],
"checksums": {
"sha1": "d2242b94eeb0f6d3810a8c71af4664f28853e1be"
}
},
"zero_functional": {
"version": "1.3.0",
"vcsRevision": "edf3b7f59119f75706da435c2b7f080a0cf4960c",
"url": "https://github.com/zero-functional/zero-functional",
"downloadMethod": "git",
"dependencies": [],
"checksums": {
"sha1": "2dc01ca0925ac1c2dcb46a0c6d9c93c57a9cddec"
}
},
"update_nim_package_version": {
"version": "0.2.0",
"vcsRevision": "5a78579fd7f88014263aed38c60327c85f6f8bcf",
"url": "https://git.jdb-software.com/jdb/update-nim-package-version",
"downloadMethod": "git",
"dependencies": [],
"checksums": {
"sha1": "37052b7ce30d5493ef24253a82a6087350b4eabb"
}
},
"data_uri": {
"version": "1.0.2",
"vcsRevision": "f43ac66e44c37edd3cc7282d75d6fa2fa031b2ec",
"url": "",
"downloadMethod": "git",
"dependencies": [
"docopt",
"filetype",
"zero_functional",
"update_nim_package_version"
],
"checksums": {
"sha1": "949c11ffab4e85ff538b0bd3e5bb193f118b56d7"
}
},
"timeutils": {
"version": "0.5.4",
"vcsRevision": "a9308cbaf3c89496b5832ddd18404dc0debe66a2",
"url": "https://git.jdb-software.com/jdb/nim-time-utils.git",
"downloadMethod": "git",
"dependencies": [],
"checksums": {
"sha1": "9ecd1020f5644bc59acb2f44ca9d30f4c7f066d3"
}
},
"cliutils": {
"version": "0.8.0",
"vcsRevision": "b1cc4fbe51d5e617789363efe716793ebe5bc5f1",
"url": "https://git.jdb-software.com/jdb/nim-cli-utils",
"downloadMethod": "git",
"dependencies": [
"docopt"
],
"checksums": {
"sha1": "5b114094c314007fa6f15e62852d62a58a3cbb62"
}
},
"langutils": {
"version": "0.4.0",
"vcsRevision": "8122660da3fc78132b823e76c9e990fd92802b0e",
"url": "https://git.jdb-software.com/jdb/nim-lang-utils.git",
"downloadMethod": "git",
"dependencies": [],
"checksums": {
"sha1": "2da09deb0e0bfc186000f0d941d06dd974bf6e58"
}
},
"httpbeast": {
"version": "0.4.1",
"vcsRevision": "abc13d11c210b614960fe8760e581d44cfb2e3e9",
"url": "https://github.com/dom96/httpbeast",
"downloadMethod": "git",
"dependencies": [
"asynctools"
],
"checksums": {
"sha1": "b23e57a401057dcb9b7fae1fb8279a6a2ce1d0b8"
}
},
"jester": {
"version": "0.5.0",
"vcsRevision": "a21b36a02b7745d6cdcda32d4ab3fba328cda17a",
"url": "https://github.com/dom96/jester/",
"downloadMethod": "git",
"dependencies": [
"httpbeast",
"asynctools"
],
"checksums": {
"sha1": "a192ca25bfc05d5de5c9a5fafca3b0cee47d82d2"
}
}
},
"tasks": {}
}

View File

@ -1,31 +1,31 @@
# Package # Package
version = "4.24.0" version = "4.29.1"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Personal issue tracker." description = "Personal issue tracker."
license = "MIT" license = "MIT"
srcDir = "src" srcDir = "src"
installExt = @["nim"] installExt = @["nim"]
bin = @["pit", "pit_api"] bin = @["pit"]
# Dependencies # Dependencies
requires @[ requires @[
"nim >= 1.4.0", "nim >= 1.4.0",
"docopt >= 0.6.8", "docopt >= 0.7.1",
"jester >= 0.5.0", "jester >= 0.6.0",
"uuids >= 0.1.10", "uuids >= 0.1.10",
"zero_functional" "zero_functional"
] ]
# Dependencies from git.jdb-software.com/nim-jdb/packages # Dependencies from git.jdb-software.com/jdb/nim-packages
requires @[ requires @[
"cliutils >= 0.8.1", "cliutils >= 0.9.1",
"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",
"https://git.jdb-software.com/jdb/update-nim-package-version >= 0.2.0" "update_nim_package_version >= 0.2.0"
] ]
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,187 +1,19 @@
## 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, tables, times, unicode]
terminal, times, unicode] import data_uri, docopt, json, timeutils, uuids
import cliutils, data_uri, docopt, json, timeutils, uuids
from nre import re from nre import 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, 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 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(";"):
@ -199,16 +31,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) =
# load the issues to make sure the order file contains all issues in the state. # load the issues to make sure the order file contains all issues in the state.
@ -231,75 +53,6 @@ proc edit(issue: Issue) =
getCurrentExceptionMsg() getCurrentExceptionMsg()
issue.store() issue.store()
proc list(
ctx: CliContext,
filter: Option[IssueFilter],
states: Option[seq[IssueState]],
showToday, showFuture,
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")))
stdout.write ctx.formatSection(ctx.issues[state], state, "", verbose)
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))
stdout.write ctx.formatSection(visibleIssues, s, indent, verbose)
# 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))
stdout.write ctx.formatSection(visibleIssues, s, indent, verbose)
trace "listing complete"
when isMainModule: when isMainModule:
try: try:
@ -314,6 +67,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"]:
@ -325,6 +81,7 @@ 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])
@ -380,7 +137,7 @@ when isMainModule:
else: newSeq[string]()) else: newSeq[string]())
ctx.cfg.tasksDir.store(issue, state) ctx.cfg.tasksDir.store(issue, state)
updatedIssues.add(issue)
stdout.writeLine formatIssue(issue) stdout.writeLine formatIssue(issue)
elif args["reorder"]: elif args["reorder"]:
@ -389,6 +146,11 @@ when isMainModule:
elif args["edit"]: elif args["edit"]:
for editRef in @(args["<ref>"]): for editRef in @(args["<ref>"]):
let propsOption =
if args["--properties"]:
some(parsePropertiesOption($args["--properties"]))
else: none(TableRef[string, string])
var stateOption = none(IssueState) var stateOption = none(IssueState)
try: stateOption = some(parseEnum[IssueState](editRef)) try: stateOption = some(parseEnum[IssueState](editRef))
@ -397,9 +159,20 @@ when isMainModule:
if stateOption.isSome: if stateOption.isSome:
let state = stateOption.get let state = stateOption.get
ctx.loadIssues(state) ctx.loadIssues(state)
for issue in ctx.issues[state]: edit(issue) for issue in ctx.issues[state]:
if propsOption.isSome:
for k,v in propsOption.get:
issue[k] = v
edit(issue)
updatedIssues.add(issue)
else: edit(ctx.cfg.tasksDir.loadIssueById(editRef)) else:
let issue = ctx.cfg.tasksDir.loadIssueById(editRef)
if propertiesOption.isSome:
for k,v in propertiesOption.get:
issue[k] = v
edit(issue)
updatedIssues.add(issue)
elif args["tag"]: elif args["tag"]:
if tagsOption.isNone: raise newException(Exception, "no tags given") if tagsOption.isNone: raise newException(Exception, "no tags given")
@ -410,6 +183,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] =
@ -423,6 +197,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"]:
@ -446,10 +221,12 @@ 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:
@ -463,7 +240,7 @@ when isMainModule:
) )
cmd &= " -g \"" & tags.join(",") & "\"" cmd &= " -g \"" & tags.join(",") & "\""
cmd &= " -n \"pit-id: " & $issue.id & "\"" cmd &= " -n \"pit-id: " & $issue.id & "\""
cmd &= " \"" & issue.summary & "\"" cmd &= " \"[" & ($issue.id)[0..<6] & "] " & issue.summary & "\""
discard execShellCmd(cmd) discard execShellCmd(cmd)
elif targetState == Done or targetState == Pending: elif targetState == Done or targetState == Pending:
discard execShellCmd("ptk stop") discard execShellCmd("ptk stop")
@ -474,6 +251,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"]:
@ -481,6 +259,7 @@ 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>"]):
@ -493,6 +272,7 @@ when isMainModule:
continue continue
issue.delete issue.delete
updatedIssues.add(issue)
elif args["list"]: elif args["list"]:
@ -540,10 +320,12 @@ when isMainModule:
filter.exclProperties.del("context") filter.exclProperties.del("context")
var listContexts = false var listContexts = false
var listTags = 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["tags"]: listTags = true
elif args["<stateOrId>"]: elif args["<stateOrId>"]:
try: try:
statesOption = statesOption =
@ -569,6 +351,21 @@ when isMainModule:
for c in uniqContexts.sorted: for c in uniqContexts.sorted:
stdout.writeLine(c.alignLeft(maxLen+2) & ctx.getIssueContextDisplayName(c)) stdout.writeLine(c.alignLeft(maxLen+2) & ctx.getIssueContextDisplayName(c))
elif listTags:
var uniqTags = newseq[string]()
if statesOption.isSome:
for state in statesOption.get: ctx.loadIssues(state)
else: ctx.loadAllIssues()
if filterOption.isSome: ctx.filterIssues(filterOption.get)
for state, issueList in ctx.issues:
for issue in issueList:
for tag in issue.tags:
if not uniqTags.contains(tag): uniqTags.add(tag)
stdout.writeLine(uniqTags.sorted.join("\n"))
# List a specific issue # List a specific issue
elif issueIdsOption.isSome: elif issueIdsOption.isSome:
for issueId in issueIdsOption.get: for issueId in issueIdsOption.get:
@ -598,6 +395,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>"]))
@ -629,6 +427,24 @@ when isMainModule:
if issuePaths.len < 2: continue if issuePaths.len < 2: continue
stdout.writeLine(issueId & ":\p " & issuePaths.join("\p ") & "\p\p") stdout.writeLine(issueId & ":\p " & issuePaths.join("\p ") & "\p\p")
elif args["sync"]:
if ctx.cfg.syncTargets.len == 0:
info "No sync targets configured"
for syncTarget in ctx.cfg.syncTargets:
let syncCtx = initSyncContext(ctx.cfg, syncTarget)
sync(syncCtx, args["--dry-run"])
# after doing stuff, sync if auto-sync is requested
if ctx.cfg.autoSync:
for syncTarget in ctx.cfg.syncTargets:
let syncCtx = initSyncContext(ctx.cfg, syncTarget)
if anyIt(
updatedIssues,
it.hasProp("context") and it["context"] == syncCtx.issueContext):
sync(syncCtx, false)
except CatchableError: except CatchableError:
fatal getCurrentExceptionMsg() fatal getCurrentExceptionMsg()
debug getCurrentException().getStackTrace() debug getCurrentException().getStackTrace()

View File

@ -1,8 +1,9 @@
const PIT_VERSION* = "4.24.0" const PIT_VERSION* = "4.29.1"
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 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]
pit edit <ref>... [options] pit edit <ref>... [options]
@ -15,6 +16,7 @@ const USAGE* = """Usage:
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
pit sync [<syncTarget>...] [options]
pit help [options] pit help [options]
Options: Options:
@ -73,11 +75,15 @@ 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.
-s, --silent Suppress all logging and status output.
""" """
const ONLINE_HELP* = """Issue States: const ONLINE_HELP* = """Issue States:
@ -101,7 +107,9 @@ Issue Properties:
created created
If present, expected to be an ISO 8601-formatted date that represents the If present, expected to be an ISO 8601-formatted date that represents the
time when the issue was created. time when the issue was created. E.g.:
created: 2023-07-13T13:28:41-05:00
completed completed
@ -110,12 +118,16 @@ Issue Properties:
property automatically when you use the "done" command, and can filter on property automatically when you use the "done" command, and can filter on
this value. this value.
completed: 2023-04-27T11:52:28-05:00
context context
Allows issues to be organized into contexts. The -c option is short-hand Allows issues to be organized into contexts. The -c option is short-hand
for '-p context:<context-name>' and the 'list contexts' command will show for '-p context:<context-name>' and the 'list contexts' command will show
all values of 'context' set in existing issues. all values of 'context' set in existing issues.
context: family
delegated-to delegated-to
When an issue now belongs to someone else, but needs to be monitored for When an issue now belongs to someone else, but needs to be monitored for
@ -123,17 +135,23 @@ Issue Properties:
note how it has been delegated. When present PIT will prepend this value note how it has been delegated. When present PIT will prepend this value
to the issue summary with an accent color. to the issue summary with an accent color.
delegated-to: Bob Ross
hide-until hide-until
When present, expected to be an ISO 8601-formatted date and used to When present, expected to be an ISO 8601-formatted date and used to
supress the display of the issue until on or after the given date. supress the display of the issue until on or after the given date.
hide-until: 2024-01-01T13:45:00-05:00
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
capture details about the dependency When present PIT will display this capture details about the dependency When present PIT will display this
value after the issue summary. value after the issue summary.
pending: Results of WCAG analysis.
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
@ -144,7 +162,7 @@ Issue Properties:
A valid recurrence value has a time value and optionally has an source A valid recurrence value has a time value and optionally has an source
issue ID. For example: issue ID. For example:
every 5 days, 10a544 recurrence: every 5 days, 10a544
The first word, "every", is expected to be either "every" or "after". The first word, "every", is expected to be either "every" or "after".
@ -162,12 +180,12 @@ Issue Properties:
Examples: Examples:
every day every day
every 2 days every 2 days
after 2 days after 2 days
every week every week
after 12 hours after 12 hours
every 2 weeks, 10a544 every 2 weeks, 10a544
tags tags

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

@ -0,0 +1,251 @@
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 adjustedTerminalWidth(): int = min(terminalWidth(), 80)
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 = 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

@ -33,13 +33,23 @@ type
PitConfig* = ref object PitConfig* = ref object
tasksDir*: string tasksDir*: string
contexts*: TableRef[string, string] contexts*: TableRef[string, 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 +78,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 +90,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,6 +129,7 @@ proc getRecurrence*(issue: Issue): Option[Recurrence] =
else: weeks(1), else: weeks(1),
cloneId: c[6])) cloneId: c[6]))
## Issue filtering ## Issue filtering
proc initFilter*(): IssueFilter = proc initFilter*(): IssueFilter =
result = IssueFilter( result = IssueFilter(
@ -173,6 +190,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 +256,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))
@ -328,11 +347,16 @@ proc loadAllIssues*(tasksDir: string): TableRef[IssueState, seq[Issue]] =
for state in IssueState: result[state] = tasksDir.loadIssues(state) for state in IssueState: result[state] = tasksDir.loadIssues(state)
proc changeState*(issue: Issue, tasksDir: string, newState: IssueState) = proc changeState*(issue: Issue, tasksDir: string, newState: IssueState) =
var dbgInfo = "[$#] changing state: $#$#" %
[ ($issue.id)[0..<6], $issue.state, $newState ]
let oldFilepath = issue.filepath let oldFilepath = issue.filepath
if newState == Done: issue.setDateTime("completed", getTime().local) if newState == Done: issue.setDateTime("completed", getTime().local)
tasksDir.store(issue, newState) tasksDir.store(issue, newState)
if oldFilePath != issue.filepath: removeFile(oldFilepath) if oldFilePath != issue.filepath: removeFile(oldFilepath)
dbgInfo &= "\n\told path: $#\n\tnew path: $#" % [oldFilePath, issue.filepath]
issue.state = newState issue.state = newState
debug dbgInfo
proc delete*(issue: Issue) = removeFile(issue.filepath) proc delete*(issue: Issue) = removeFile(issue.filepath)
@ -368,6 +392,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
@ -411,6 +436,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
@ -434,8 +460,10 @@ 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", "")) 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()
@ -450,3 +478,38 @@ 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)

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

@ -0,0 +1,179 @@
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]
parent*: Option[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(),
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"
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

@ -1,11 +1,17 @@
## Personal Issue Tracker API Interface ## Personal Issue Tracker API Interface
## ==================================== ## ====================================
#
# **NOTE** This is currently not being built as it no longer works under Nim
# 2.x due to the inability to call system calls (invoke pit via cli) in a
# gc-safe manner. It should be rewritten to use the functionality exposed by
# libpit directly rather than calling the pit cli executable. Unfortunately
# this would require a non-trivial rewrite.
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