Compare commits

...

12 Commits
4.3.0 ... 4.6.1

5 changed files with 221 additions and 85 deletions

View File

@ -11,5 +11,8 @@ bin = @["pit", "pit_api"]
# Dependencies # Dependencies
requires @[ "nim >= 0.18.0", "cliutils 0.4.1", "docopt 0.6.5", "jester 0.2.0", requires @[ "nim >= 0.19.0", "docopt 0.6.8", "jester 0.4.1", "uuids 0.1.10" ]
"timeutils 0.3.0", "uuids 0.1.9" ]
requires "https://git.jdb-labs.com/jdb/nim-cli-utils.git >= 0.6.1"
requires "https://git.jdb-labs.com/jdb/nim-lang-utils.git >= 0.4.0"
requires "https://git.jdb-labs.com/jdb/nim-time-utils.git >= 0.4.0"

View File

@ -4,7 +4,8 @@
import cliutils, docopt, json, logging, options, os, ospaths, sequtils, import cliutils, docopt, json, logging, options, os, ospaths, sequtils,
tables, terminal, times, timeutils, unicode, uuids tables, terminal, times, timeutils, unicode, uuids
import strutils except capitalize, toUpper, toLower from nre import re
import strutils except capitalize, strip, toUpper, toLower
import pitpkg/private/libpit import pitpkg/private/libpit
export libpit export libpit
@ -20,6 +21,11 @@ type
termWidth*: int termWidth*: int
triggerPtk*, verbose*: bool triggerPtk*, verbose*: bool
let EDITOR =
if existsEnv("EDITOR"): getEnv("EDITOR")
else: "vi"
proc initContext(args: Table[string, Value]): CliContext = proc initContext(args: Table[string, Value]): CliContext =
let pitCfg = loadConfig(args) let pitCfg = loadConfig(args)
@ -70,11 +76,21 @@ proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
var showDetails = not issue.details.isNilOrWhitespace and verbose var showDetails = not issue.details.isNilOrWhitespace and verbose
# Wrap and write the summary. var prefixLen = 0
var wrappedSummary = (" ".repeat(5) & issue.summary).wordWrap(width - 2).indent(2 + indent.len) var summaryIndentLen = indent.len + 7
wrappedSummary = wrappedSummary[(6 + indent.len)..^1]
if issue.hasProp("delegated-to"): prefixLen += issue["delegated-to"].len + 2 # space for the ':' and ' '
# Wrap and write the summary.
var wrappedSummary = ("+".repeat(prefixLen) & issue.summary).wordWrap(width - summaryIndentLen).indent(summaryIndentLen)
wrappedSummary = wrappedSummary[(prefixLen + summaryIndentLen)..^1]
result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true) & " "
if issue.hasProp("delegated-to"):
result &= (issue["delegated-to"] & ": ").withColor(fgGreen)
result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true)
result &= wrappedSummary.withColor(fgWhite) result &= wrappedSummary.withColor(fgWhite)
if issue.tags.len > 0: if issue.tags.len > 0:
@ -86,9 +102,9 @@ proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
if issue.hasProp("pending"): if issue.hasProp("pending"):
let startIdx = "Pending: ".len let startIdx = "Pending: ".len
var pendingText = issue["pending"].wordWrap(width - startIdx - 2) var pendingText = issue["pending"].wordWrap(width - startIdx - summaryIndentLen)
.indent(startIdx) .indent(startIdx)
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2) pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(summaryIndentLen)
result &= "\n" & pendingText.withColor(fgCyan) result &= "\n" & pendingText.withColor(fgCyan)
if showDetails: if showDetails:
@ -100,16 +116,9 @@ proc formatSectionIssueList(ctx: CliContext, issues: seq[Issue], width: int,
indent: string, verbose: bool): string = indent: string, verbose: bool): string =
result = "" result = ""
var topPadded = true
for i in issues: for i in issues:
var issueText = ctx.formatSectionIssue(i, width, indent, verbose) var issueText = ctx.formatSectionIssue(i, width, indent, verbose)
if issueText.splitLines.len > 1: result &= issueText & "\n"
if topPadded: result &= issueText & "\n\n"
else: result &= "\n" & issueText & "\n\n"
topPadded = true
else:
result &= issueText & "\n"
topPadded = false
proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState, proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
indent = "", verbose = false): string = indent = "", verbose = false): string =
@ -162,16 +171,18 @@ proc writeHeader(ctx: CliContext, header: string) =
stdout.writeLine('~'.repeat(ctx.termWidth)) stdout.writeLine('~'.repeat(ctx.termWidth))
stdout.resetAttributes stdout.resetAttributes
proc reorder(ctx: CliContext, state: IssueState) =
# load the issues to make sure the order file contains all issues in the state.
ctx.loadIssues(state)
discard os.execShellCmd(EDITOR & " '" & (ctx.tasksDir / $state / "order.txt") & "' </dev/tty >/dev/tty")
proc edit(issue: Issue) = proc edit(issue: Issue) =
# Write format comments (to help when editing) # Write format comments (to help when editing)
writeFile(issue.filepath, toStorageFormat(issue, true)) writeFile(issue.filepath, toStorageFormat(issue, true))
let editor = discard os.execShellCmd(EDITOR & " '" & issue.filepath & "' </dev/tty >/dev/tty")
if existsEnv("EDITOR"): getEnv("EDITOR")
else: "vi"
discard os.execShellCmd(editor & " " & issue.filepath & " </dev/tty >/dev/tty")
try: try:
# Try to parse the newly-edited issue to make sure it was successful. # Try to parse the newly-edited issue to make sure it was successful.
@ -182,7 +193,7 @@ proc edit(issue: Issue) =
getCurrentExceptionMsg() getCurrentExceptionMsg()
issue.store() issue.store()
proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState], today, future, verbose: bool) = proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState], showToday, showFuture, verbose: bool) =
if state.isSome: if state.isSome:
ctx.loadIssues(state.get) ctx.loadIssues(state.get)
@ -193,6 +204,12 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState
ctx.loadAllIssues() ctx.loadAllIssues()
if filter.isSome: ctx.filterIssues(filter.get) if filter.isSome: ctx.filterIssues(filter.get)
let today = showToday and [Current, TodoToday].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: "" let indent = if today and future: " " else: ""
# Today's items # Today's items
@ -227,6 +244,10 @@ Usage:
pit list [<listable>] [options] pit list [<listable>] [options]
pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options] pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options]
pit edit <ref>... pit edit <ref>...
pit tag <id>... [options]
pit untag <id>... [options]
pit reorder <state>
pit delegate <id> <delegated-to>
pit ( delete | rm ) <id>... pit ( delete | rm ) <id>...
Options: Options:
@ -246,6 +267,12 @@ Options:
-F, --future Limit to future issues. -F, --future Limit to future issues.
-m, --match <pattern> Limit to issues whose summaries match the given
pattern (PCRE regex supported).
-M, --match-all <pat> Limit to the issues whose summaries or details
match the given pattern (PCRE regex supported).
-v, --verbose Show issue details when listing issues. -v, --verbose Show issue details when listing issues.
-q, --quiet Suppress verbose output. -q, --quiet Suppress verbose output.
@ -314,6 +341,9 @@ Options:
stdout.writeLine ctx.formatIssue(issue) stdout.writeLine ctx.formatIssue(issue)
elif args["reorder"]:
ctx.reorder(parseEnum[IssueState]($args["<state>"]))
elif args["edit"]: elif args["edit"]:
for editRef in @(args["<ref>"]): for editRef in @(args["<ref>"]):
@ -329,6 +359,29 @@ Options:
else: edit(ctx.tasksDir.loadIssueById(editRef)) else: edit(ctx.tasksDir.loadIssueById(editRef))
elif args["tag"]:
if not args["--tags"]: raise newException(Exception, "no tags given")
let newTags = ($args["--tags"]).split(",").mapIt(it.strip)
for id in @(args["<id>"]):
var issue = ctx.tasksDir.loadIssueById(id)
issue.tags = deduplicate(issue.tags & newTags)
issue.store()
elif args["untag"]:
let tagsToRemove: seq[string] =
if args["--tags"]: ($args["--tags"]).split(",").mapIt(it.strip)
else: @[]
for id in @(args["<id>"]):
var issue = ctx.tasksDir.loadIssueById(id)
if tagsToRemove.len > 0:
issue.tags = issue.tags.filter(
proc (tag: string): bool = not tagsToRemove.anyIt(it == tag))
else: issue.tags = @[]
issue.store()
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"]:
@ -358,6 +411,13 @@ Options:
elif targetState == Done or targetState == Pending: elif targetState == Done or targetState == Pending:
discard execShellCmd("ptk stop") discard execShellCmd("ptk stop")
elif args["delegate"]:
let issue = ctx.tasksDir.loadIssueById($(args["<id>"]))
issue["delegated-to"] = $args["<delegated-to>"]
issue.store()
elif args["delete"] or args["rm"]: elif args["delete"] or args["rm"]:
for id in @(args["<id>"]): for id in @(args["<id>"]):
@ -380,6 +440,15 @@ Options:
filter.properties = propertiesOption.get filter.properties = propertiesOption.get
filterOption = some(filter) 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 no "context" property is given, use the default (if we have one)
if ctx.defaultContext.isSome and not filter.properties.hasKey("context"): if ctx.defaultContext.isSome and not filter.properties.hasKey("context"):
stderr.writeLine("Limiting to default context: " & ctx.defaultContext.get) stderr.writeLine("Limiting to default context: " & ctx.defaultContext.get)

View File

@ -1,7 +1,7 @@
## Personal Issue Tracker API Interface ## Personal Issue Tracker API Interface
## ==================================== ## ====================================
import asyncdispatch, cliutils, docopt, jester, json, logging, 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 pitpkg/private/libpit
@ -18,6 +18,20 @@ const TXT = "text/plain"
proc raiseEx(reason: string): void = raise newException(Exception, reason) proc raiseEx(reason: string): void = raise newException(Exception, reason)
template halt(code: HttpCode,
headers: RawHeaders,
content: string): typed =
## Immediately replies with the specified request. This means any further
## code will not be executed after calling this template in the current
## route.
bind TCActionSend, newHttpHeaders
result[0] = CallbackAction.TCActionSend
result[1] = code
result[2] = some(headers)
result[3] = content
result.matched = true
break allRoutes
template checkAuth(cfg: PitApiCfg) = template checkAuth(cfg: PitApiCfg) =
## Check this request for authentication and authorization information. ## Check this request for authentication and authorization information.
## If the request is not authorized, this template sets up the 401 response ## If the request is not authorized, this template sets up the 401 response
@ -40,30 +54,10 @@ template checkAuth(cfg: PitApiCfg) =
except: except:
stderr.writeLine "Auth failed: " & getCurrentExceptionMsg() stderr.writeLine "Auth failed: " & getCurrentExceptionMsg()
response.data[0] = CallbackAction.TCActionSend halt(
response.data[1] = Http401 Http401,
response.data[2]["WWW-Authenticate"] = "Bearer" @{"Content-Type": TXT},
response.data[2]["Content-Type"] = TXT getCurrentExceptionMsg())
response.data[3] = getCurrentExceptionMsg()
proc paramsToArgs(params: StringTableRef): tuple[stripAnsi: bool, args: seq[string]] =
result = (false, @[])
if params.hasKey("color"):
if params["color"] != "true":
result[0] = true
for k,v in params:
if k == "color": continue
elif k.startsWith("arg"): result[1].add(v) # support ?arg1=val1&arg2=val2 -> cmd val1 val2
else :
result[1].add("--" & k)
if v != "true": result[1].add(v) # support things like ?verbose=true -> cmd --verbose
let STRIP_ANSI_REGEX = re"\x1B\[([0-9]{1,2}(;[0-9]{1,2})?)?[m|K]"
proc stripAnsi(str: string): string =
return str.replace(STRIP_ANSI_REGEX, "")
proc start*(cfg: PitApiCfg) = proc start*(cfg: PitApiCfg) =
@ -79,20 +73,29 @@ proc start*(cfg: PitApiCfg) =
resp("pong", TXT) resp("pong", TXT)
get "/issues": get "/issues":
checkAuth(cfg); if not authed: return true checkAuth(cfg)
var (stripAnsi, args) = paramsToArgs(request.params) var args = queryParamsToCliArgs(request.params)
args = @["list"] & args args = @["list"] & args
info "args: \n" & args.join(" ") info "args: \n" & args.join(" ")
let execResult = execWithOutput("pit", ".", args) let execResult = execWithOutput("pit", ".", args)
if execResult[2] != 0: resp(Http500, stripAnsi($execResult[0] & "\n" & $execResult[1]), TXT) if execResult[2] != 0: resp(Http500, stripAnsi($execResult[0] & "\n" & $execResult[1]), TXT)
else: else: resp(stripAnsi(execResult[0]), TXT)
if stripAnsi: resp(stripAnsi(execResult[0]), TXT)
else: resp(execResult[0], TXT)
post "/issues": post "/issues":
checkAuth(cfg); if not authed: return true checkAuth(cfg)
get "/issue/@issueId":
checkAuth(cfg)
var args = queryParamsToCliArgs(request.params)
args = @["list", @"issueId"] & args
info "args: \n" & args.join(" ")
let execResult = execWithOutput("pit", ".", args)
if execResult[2] != 0: resp(Http500, stripAnsi($execResult[0] & "\n" & $execResult[1]), TXT)
else: resp(stripAnsi(execResult[0]), TXT)
waitFor(stopFuture) waitFor(stopFuture)

View File

@ -1,7 +1,8 @@
import cliutils, docopt, json, logging, options, os, ospaths, sequtils, import cliutils, docopt, json, logging, langutils, options, os, ospaths,
strutils, tables, times, timeutils, uuids sequtils, strutils, tables, times, timeutils, uuids
from nre import find, match, re, Regex
from nre import re, match
type type
Issue* = ref object Issue* = ref object
id*: UUID id*: UUID
@ -19,8 +20,9 @@ type
Dormant = "dormant" Dormant = "dormant"
IssueFilter* = ref object IssueFilter* = ref object
completedRange*: Option[tuple[b, e: DateTime]]
fullMatch*, summaryMatch*: Option[Regex]
properties*: TableRef[string, string] properties*: TableRef[string, string]
completedRange*: tuple[b, e: DateTime]
PitConfig* = ref object PitConfig* = ref object
tasksDir*: string tasksDir*: string
@ -62,22 +64,38 @@ proc setDateTime*(issue: Issue, key: string, dt: DateTime) =
proc initFilter*(): IssueFilter = proc initFilter*(): IssueFilter =
result = IssueFilter( result = IssueFilter(
properties: newTable[string,string](), completedRange: none(tuple[b, e: DateTime]),
completedRange: (fromUnix(0).local, fromUnix(253400659199).local)) fullMatch: none(Regex),
summaryMatch: none(Regex),
properties: newTable[string, string]())
proc initFilter*(props: TableRef[string, string]): IssueFilter = proc propsFilter*(props: TableRef[string, string]): IssueFilter =
if isNil(props): if isNil(props):
raise newException(ValueError, raise newException(ValueError,
"cannot initialize property filter without properties") "cannot initialize property filter without properties")
result = IssueFilter( result = initFilter()
properties: props, result.properties = props
completedRange: (fromUnix(0).local, fromUnix(253400659199).local))
proc dateFilter*(range: tuple[b, e: DateTime]): IssueFilter =
result = initFilter()
result.completedRange = some(range)
proc summaryMatchFilter*(pattern: string): IssueFilter =
result = initFilter()
result.summaryMatch = some(re("(?i)" & pattern))
proc fullMatchFilter*(pattern: string): IssueFilter =
result = initFilter()
result.fullMatch = some(re("(?i)" & pattern))
proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] =
result = newTable[string, seq[Issue]]()
for i in issues:
let key = if i.hasProp(propertyKey): i[propertyKey] else: ""
if not result.hasKey(key): result[key] = newSeq[Issue]()
result[key].add(i)
proc initFilter*(range: tuple[b, e: DateTime]): IssueFilter =
result = IssueFilter(
properties: newTable[string, string](),
completedRange: range)
## Parse and format issues ## Parse and format issues
proc fromStorageFormat*(id: string, issueTxt: string): Issue = proc fromStorageFormat*(id: string, issueTxt: string): Issue =
@ -128,7 +146,8 @@ proc toStorageFormat*(issue: Issue, withComments = false): string =
if withComments: lines.add("# Summary (one line):") if withComments: lines.add("# Summary (one line):")
lines.add(issue.summary) lines.add(issue.summary)
if withComments: lines.add("# Properties (\"key:value\" per line):") if withComments: lines.add("# Properties (\"key:value\" per line):")
for key, val in issue.properties: lines.add(key & ": " & val) for key, val in issue.properties:
if not val.isNilOrWhitespace: lines.add(key & ": " & val)
if issue.tags.len > 0: lines.add("tags: " & issue.tags.join(",")) if issue.tags.len > 0: lines.add("tags: " & issue.tags.join(","))
if not isNilOrWhitespace(issue.details) or withComments: if not isNilOrWhitespace(issue.details) or withComments:
if withComments: lines.add("# Details go below the \"--------\"") if withComments: lines.add("# Details go below the \"--------\"")
@ -160,40 +179,82 @@ proc store*(tasksDir: string, issue: Issue, state: IssueState, withComments = fa
else: else:
issue.filepath = stateDir / filename issue.filepath = stateDir / filename
issue.store() issue.store(withComments)
proc storeOrder*(issues: seq[Issue], path: string) =
var orderLines = newSeq[string]()
for context, issues in issues.groupBy("context"):
orderLines.add("> " & context)
for issue in issues: orderLines.add($issue.id & " " & issue.summary)
orderLines.add("")
let orderFile = path / "order.txt"
orderFile.writeFile(orderLines.join("\n"))
proc loadIssues*(path: string): seq[Issue] = proc loadIssues*(path: string): seq[Issue] =
result = @[] let orderFile = path / "order.txt"
let orderedIds =
if fileExists(orderFile):
toSeq(orderFile.lines)
.mapIt(it.split(' ')[0])
.deduplicate
.filterIt(not it.startsWith("> ") and not it.isNilOrWhitespace)
else: newSeq[string]()
type TaggedIssue = tuple[issue: Issue, ordered: bool]
var unorderedIssues: seq[TaggedIssue] = @[]
for path in walkDirRec(path): for path in walkDirRec(path):
if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome(): if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome():
result.add(loadIssue(path)) unorderedIssues.add((loadIssue(path), false))
result = @[]
# Add all ordered issues in order
for id in orderedIds:
let idx = unorderedIssues.indexOf(($it.issue.id).startsWith(id))
if idx > 0:
result.add(unorderedIssues[idx].issue)
unorderedIssues[idx].ordered = true
# Add all remaining, unordered issues in the order they were loaded
for taggedIssue in unorderedIssues:
if taggedIssue.ordered: continue
result.add(taggedIssue.issue)
# Finally, save current order
result.storeOrder(path)
proc changeState*(issue: Issue, tasksDir: string, newState: IssueState) = proc changeState*(issue: Issue, tasksDir: string, newState: IssueState) =
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)
removeFile(oldFilepath) if oldFilePath != issue.filepath: removeFile(oldFilepath)
proc delete*(issue: Issue) = removeFile(issue.filepath) proc delete*(issue: Issue) = removeFile(issue.filepath)
## Utilities for working with issue collections. ## Utilities for working with issue collections.
proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] =
result = newTable[string, seq[Issue]]()
for i in issues:
let key = if i.hasProp(propertyKey): i[propertyKey] else: ""
if not result.hasKey(key): result[key] = newSeq[Issue]()
result[key].add(i)
proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] = proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
result = issues result = issues
for k,v in filter.properties: for k,v in filter.properties:
result = result.filterIt(it.hasProp(k) and it[k] == v) result = result.filterIt(it.hasProp(k) and it[k] == v)
result = result.filterIt(not it.hasProp("completed") or if filter.completedRange.isSome:
it.getDateTime("completed").between( let range = filter.completedRange.get
filter.completedRange.b, result = result.filterIt(
filter.completedRange.e)) not it.hasProp("completed") or
it.getDateTime("completed").between(range.b, range.e))
if filter.summaryMatch.isSome:
let p = filter.summaryMatch.get
result = result.filterIt(it.summary.find(p).isSome)
if filter.fullMatch.isSome:
let p = filter.fullMatch.get
result = result.filterIt( it.summary.find(p).isSome or it.details.find(p).isSome)
### Configuration utilities ### Configuration utilities
proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitConfig = proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitConfig =

View File

@ -1 +1 @@
const PIT_VERSION = "4.3.0" const PIT_VERSION* = "4.6.1"