Compare commits
9 Commits
Author | SHA1 | Date | |
---|---|---|---|
2b5f82203c | |||
29959a6a8d | |||
6f247032a3 | |||
efd5f6adff | |||
49c5753ef1 | |||
3bdb2ecb1f | |||
28569a643e | |||
97eb286e32 | |||
fcab7a4cc6 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
|||||||
*.sw*
|
*.sw*
|
||||||
nimcache/
|
nimcache/
|
||||||
/pit
|
/pit
|
||||||
|
/pit_api
|
||||||
|
@ -1,12 +1,15 @@
|
|||||||
# Package
|
# Package
|
||||||
|
|
||||||
version = "4.0.0"
|
include "src/pitpkg/private/version.nim"
|
||||||
|
|
||||||
|
version = PIT_VERSION
|
||||||
author = "Jonathan Bernard"
|
author = "Jonathan Bernard"
|
||||||
description = "Personal issue tracker."
|
description = "Personal issue tracker."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
srcDir = "src"
|
srcDir = "src"
|
||||||
bin = @["pit"]
|
bin = @["pit", "pit_api"]
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
|
|
||||||
requires @["nim >= 0.18.0", "uuids 0.1.9", "docopt 0.6.5", "cliutils 0.3.4", "timeutils 0.3.0"]
|
requires @[ "nim >= 0.18.0", "cliutils 0.4.1", "docopt 0.6.5", "jester 0.2.0",
|
||||||
|
"timeutils 0.3.0", "uuids 0.1.9" ]
|
||||||
|
333
src/pit.nim
333
src/pit.nim
@ -1,66 +1,44 @@
|
|||||||
## Personal Issue Tracker
|
## Personal Issue Tracker CLI interface
|
||||||
## ======================
|
## ====================================
|
||||||
##
|
|
||||||
|
|
||||||
import cliutils, docopt, json, logging, options, os, ospaths, sequtils,
|
import cliutils, docopt, json, logging, options, os, ospaths, sequtils,
|
||||||
tables, terminal, times, unicode, uuids
|
tables, terminal, times, timeutils, unicode, uuids
|
||||||
|
|
||||||
import strutils except capitalize
|
import strutils except capitalize, toUpper, toLower
|
||||||
import pit/private/libpit
|
import pitpkg/private/libpit
|
||||||
export libpit
|
export libpit
|
||||||
|
|
||||||
|
include "pitpkg/private/version.nim"
|
||||||
|
|
||||||
type
|
type
|
||||||
CliContext = ref object
|
CliContext = ref object
|
||||||
autoList, triggerPtk: bool
|
cfg*: PitConfig
|
||||||
tasksDir*: string
|
|
||||||
contexts*: TableRef[string, string]
|
contexts*: TableRef[string, string]
|
||||||
|
defaultContext*: Option[string]
|
||||||
|
tasksDir*: string
|
||||||
issues*: TableRef[IssueState, seq[Issue]]
|
issues*: TableRef[IssueState, seq[Issue]]
|
||||||
termWidth*: int
|
termWidth*: int
|
||||||
|
triggerPtk*, verbose*: bool
|
||||||
|
|
||||||
proc initContext(args: Table[string, Value]): CliContext =
|
proc initContext(args: Table[string, Value]): CliContext =
|
||||||
let pitrcLocations = @[
|
let pitCfg = loadConfig(args)
|
||||||
if args["--config"]: $args["--config"] else: "",
|
|
||||||
".pitrc", $getEnv("PITRC"), $getEnv("HOME") & "/.pitrc"]
|
|
||||||
|
|
||||||
var pitrcFilename: string =
|
let cliJson =
|
||||||
foldl(pitrcLocations, if len(a) > 0: a elif existsFile(b): b else: "")
|
if pitCfg.cfg.json.hasKey("cli"): pitCfg.cfg.json["cli"]
|
||||||
|
else: newJObject()
|
||||||
|
|
||||||
if not existsFile(pitrcFilename):
|
let cliCfg = CombinedConfig(docopt: args, json: cliJson)
|
||||||
warn "pit: could not find .pitrc file: " & pitrcFilename
|
|
||||||
if isNilOrWhitespace(pitrcFilename):
|
|
||||||
pitrcFilename = $getEnv("HOME") & "/.pitrc"
|
|
||||||
var cfgFile: File
|
|
||||||
try:
|
|
||||||
cfgFile = open(pitrcFilename, fmWrite)
|
|
||||||
cfgFile.write("{\"tasksDir\": \"/path/to/tasks\"}")
|
|
||||||
except: warn "pit: could not write default .pitrc to " & pitrcFilename
|
|
||||||
finally: close(cfgFile)
|
|
||||||
|
|
||||||
var cfgJson: JsonNode
|
|
||||||
try: cfgJson = parseFile(pitrcFilename)
|
|
||||||
except: raise newException(IOError,
|
|
||||||
"unable to read config file: " & pitrcFilename &
|
|
||||||
"\x0D\x0A" & getCurrentExceptionMsg())
|
|
||||||
|
|
||||||
let cfg = CombinedConfig(docopt: args, json: cfgJson)
|
|
||||||
|
|
||||||
result = CliContext(
|
result = CliContext(
|
||||||
autoList: cfgJson.getOrDefault("autoList").getBool(false),
|
contexts: pitCfg.contexts,
|
||||||
contexts: newTable[string,string](),
|
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]](),
|
issues: newTable[IssueState, seq[Issue]](),
|
||||||
tasksDir: cfg.getVal("tasks-dir", ""),
|
tasksDir: pitCfg.tasksDir,
|
||||||
termWidth: parseInt(cfg.getVal("term-width", "80")),
|
termWidth: parseInt(cliCfg.getVal("term-width", "80")),
|
||||||
triggerPtk: cfgJson.getOrDefault("triggerPtk").getBool(false))
|
triggerPtk: cliJson.getOrDefault("triggerPtk").getBool(false))
|
||||||
|
|
||||||
if cfgJson.hasKey("contexts"):
|
|
||||||
for k, v in cfgJson["contexts"]:
|
|
||||||
result.contexts[k] = v.getStr()
|
|
||||||
|
|
||||||
if isNilOrWhitespace(result.tasksDir):
|
|
||||||
raise newException(Exception, "no tasks directory configured")
|
|
||||||
|
|
||||||
if not existsDir(result.tasksDir):
|
|
||||||
raise newException(Exception, "cannot find tasks dir: " & result.tasksDir)
|
|
||||||
|
|
||||||
proc getIssueContextDisplayName(ctx: CliContext, context: string): string =
|
proc getIssueContextDisplayName(ctx: CliContext, context: string): string =
|
||||||
if not ctx.contexts.hasKey(context):
|
if not ctx.contexts.hasKey(context):
|
||||||
@ -68,73 +46,93 @@ proc getIssueContextDisplayName(ctx: CliContext, context: string): string =
|
|||||||
else: return context.capitalize()
|
else: return context.capitalize()
|
||||||
return ctx.contexts[context]
|
return ctx.contexts[context]
|
||||||
|
|
||||||
proc writeIssue(ctx: CliContext, issue: Issue, state: IssueState,
|
proc formatIssue(ctx: CliContext, issue: Issue): string =
|
||||||
width: int, indent: string, topPadded: bool) =
|
result = ($issue.id).withColor(fgBlack, true) & "\n"&
|
||||||
var showDetails = not issue.details.isNilOrWhitespace
|
issue.summary.withColor(fgWhite) & "\n"
|
||||||
|
|
||||||
if showDetails and not topPadded: stdout.writeLine("")
|
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.isNilOrWhitespace:
|
||||||
|
result &= issue.details.strip.withColor(fgCyan) & "\n"
|
||||||
|
|
||||||
|
proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
|
||||||
|
verbose = false): string =
|
||||||
|
|
||||||
|
result = ""
|
||||||
|
|
||||||
|
var showDetails = not issue.details.isNilOrWhitespace and verbose
|
||||||
|
|
||||||
# Wrap and write the summary.
|
# Wrap and write the summary.
|
||||||
var wrappedSummary = (" ".repeat(5) & issue.summary).wordWrap(width - 2).indent(2 + indent.len)
|
var wrappedSummary = (" ".repeat(5) & issue.summary).wordWrap(width - 2).indent(2 + indent.len)
|
||||||
wrappedSummary = wrappedSummary[(6 + indent.len)..^1]
|
wrappedSummary = wrappedSummary[(6 + indent.len)..^1]
|
||||||
stdout.setForegroundColor(fgBlack, true)
|
|
||||||
stdout.write(indent & ($issue.id)[0..<6])
|
result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true)
|
||||||
stdout.setForegroundColor(fgCyan, false)
|
result &= wrappedSummary.withColor(fgWhite)
|
||||||
stdout.write(wrappedSummary)
|
|
||||||
|
|
||||||
if issue.tags.len > 0:
|
if issue.tags.len > 0:
|
||||||
stdout.setForegroundColor(fgGreen, false)
|
let tagsStr = "(" & issue.tags.join(", ") & ")"
|
||||||
let tagsStr = "(" & issue.tags.join(",") & ")"
|
if (result.splitLines[^1].len + tagsStr.len + 1) > (width - 2):
|
||||||
if (wrappedSummary.splitLines[^1].len + tagsStr.len + 1) < (width - 2):
|
result &= "\n" & indent
|
||||||
stdout.writeLine(" " & tagsStr)
|
result &= " " & tagsStr.withColor(fgGreen)
|
||||||
else:
|
|
||||||
stdout.writeLine("\n" & indent & " " & tagsStr)
|
|
||||||
else: stdout.writeLine("")
|
|
||||||
stdout.resetAttributes
|
|
||||||
|
|
||||||
if state == Pending and 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 - 2)
|
||||||
.indent(startIdx)
|
.indent(startIdx)
|
||||||
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2)
|
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2)
|
||||||
stdout.writeLine(pendingText)
|
result &= "\n" & pendingText.withColor(fgCyan)
|
||||||
|
|
||||||
if showDetails: stdout.writeLine(issue.details.indent(indent.len + 2))
|
if showDetails:
|
||||||
|
result &= "\n" & issue.details.strip.indent(indent.len + 2).withColor(fgCyan)
|
||||||
|
|
||||||
|
result &= termReset
|
||||||
|
|
||||||
proc writeSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
|
proc formatSectionIssueList(ctx: CliContext, issues: seq[Issue], width: int,
|
||||||
indent = "") =
|
indent: string, verbose: bool): string =
|
||||||
|
|
||||||
|
result = ""
|
||||||
|
var topPadded = true
|
||||||
|
for i in issues:
|
||||||
|
var issueText = ctx.formatSectionIssue(i, width, indent, verbose)
|
||||||
|
if issueText.splitLines.len > 1:
|
||||||
|
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,
|
||||||
|
indent = "", verbose = false): string =
|
||||||
let innerWidth = ctx.termWidth - (indent.len * 2)
|
let innerWidth = ctx.termWidth - (indent.len * 2)
|
||||||
|
|
||||||
stdout.setForegroundColor(fgBlue, true)
|
result = termColor(fgBlue) &
|
||||||
stdout.writeLine(indent & ".".repeat(innerWidth))
|
(indent & ".".repeat(innerWidth)) & "\n" &
|
||||||
stdout.writeLine(state.displayName.center(ctx.termWidth))
|
state.displayName.center(ctx.termWidth) & "\n\n" &
|
||||||
stdout.writeLine("")
|
termReset
|
||||||
stdout.resetAttributes
|
|
||||||
|
|
||||||
var topPadded = true
|
|
||||||
|
|
||||||
let issuesByContext = issues.groupBy("context")
|
let issuesByContext = issues.groupBy("context")
|
||||||
|
|
||||||
if issues.len > 5 and issuesByContext.len > 1:
|
if issues.len > 5 and issuesByContext.len > 1:
|
||||||
for context, ctxIssues in issuesByContext:
|
for context, ctxIssues in issuesByContext:
|
||||||
stdout.setForegroundColor(fgYellow, false)
|
|
||||||
stdout.writeLine(indent & ctx.getIssueContextDisplayName(context) & ":")
|
|
||||||
stdout.writeLine("")
|
|
||||||
stdout.resetAttributes
|
|
||||||
|
|
||||||
for i in ctxIssues:
|
result &= termColor(fgYellow) &
|
||||||
ctx.writeIssue(i, state, innerWidth - 2, indent & " ", topPadded)
|
indent & ctx.getIssueContextDisplayName(context) & ":" &
|
||||||
topPadded = not i.details.isNilOrWhitespace
|
termReset & "\n\n"
|
||||||
|
|
||||||
if not topPadded: stdout.writeLine("")
|
result &= ctx.formatSectionIssueList(ctxIssues, innerWidth - 2, indent & " ", verbose)
|
||||||
|
result &= "\n"
|
||||||
|
|
||||||
else:
|
else: result &= ctx.formatSectionIssueList(issues, innerWidth, indent, verbose)
|
||||||
for i in issues:
|
|
||||||
ctx.writeIssue(i, state, innerWidth, indent, topPadded)
|
|
||||||
topPadded = not i.details.isNilOrWhitespace
|
|
||||||
|
|
||||||
stdout.writeLine("")
|
|
||||||
|
|
||||||
proc loadIssues(ctx: CliContext, state: IssueState) =
|
proc loadIssues(ctx: CliContext, state: IssueState) =
|
||||||
ctx.issues[state] = loadIssues(ctx.tasksDir / $state)
|
ctx.issues[state] = loadIssues(ctx.tasksDir / $state)
|
||||||
@ -184,12 +182,12 @@ proc edit(issue: Issue) =
|
|||||||
getCurrentExceptionMsg()
|
getCurrentExceptionMsg()
|
||||||
issue.store()
|
issue.store()
|
||||||
|
|
||||||
proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState], today, future: bool) =
|
proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState], today, future, verbose: bool) =
|
||||||
|
|
||||||
if state.isSome:
|
if state.isSome:
|
||||||
ctx.loadIssues(state.get)
|
ctx.loadIssues(state.get)
|
||||||
if filter.isSome: ctx.filterIssues(filter.get)
|
if filter.isSome: ctx.filterIssues(filter.get)
|
||||||
ctx.writeSection(ctx.issues[state.get], state.get)
|
stdout.write ctx.formatSection(ctx.issues[state.get], state.get, "", verbose)
|
||||||
return
|
return
|
||||||
|
|
||||||
ctx.loadAllIssues()
|
ctx.loadAllIssues()
|
||||||
@ -203,14 +201,14 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState
|
|||||||
|
|
||||||
for s in [Current, TodoToday]:
|
for s in [Current, TodoToday]:
|
||||||
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
|
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
|
||||||
ctx.writeSection(ctx.issues[s], s, indent)
|
stdout.write ctx.formatSection(ctx.issues[s], s, indent, verbose)
|
||||||
|
|
||||||
if ctx.issues.hasKey(Done):
|
if ctx.issues.hasKey(Done):
|
||||||
let doneIssues = ctx.issues[Done].filterIt(
|
let doneIssues = ctx.issues[Done].filterIt(
|
||||||
it.hasProp("completed") and
|
it.hasProp("completed") and
|
||||||
sameDay(getTime().local, it.getDateTime("completed")))
|
sameDay(getTime().local, it.getDateTime("completed")))
|
||||||
if doneIssues.len > 0:
|
if doneIssues.len > 0:
|
||||||
ctx.writeSection(doneIssues, Done, indent)
|
stdout.write ctx.formatSection(doneIssues, Done, indent, verbose)
|
||||||
|
|
||||||
# Future items
|
# Future items
|
||||||
if future:
|
if future:
|
||||||
@ -218,47 +216,56 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState
|
|||||||
|
|
||||||
for s in [Pending, Todo]:
|
for s in [Pending, Todo]:
|
||||||
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
|
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
|
||||||
ctx.writeSection(ctx.issues[s], s, indent)
|
stdout.write ctx.formatSection(ctx.issues[s], s, indent, verbose)
|
||||||
|
|
||||||
when isMainModule:
|
when isMainModule:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
let doc = """
|
let doc = """
|
||||||
Usage:
|
Usage:
|
||||||
pit new <summary> [<state>] [options]
|
pit ( new | add) <summary> [<state>] [options]
|
||||||
pit list [<state>] [options]
|
pit list [<listable>] [options]
|
||||||
pit ( start | done | pending | do-today | todo ) <id>...
|
pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options]
|
||||||
pit edit <id>
|
pit edit <id>...
|
||||||
|
pit ( delete | rm ) <id>...
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
|
|
||||||
-h, --help Print this usage information.
|
-h, --help Print this usage information.
|
||||||
|
|
||||||
-t, --tags <tags> Specify tags for an issue.
|
|
||||||
|
|
||||||
-p, --properties <props> Specify properties. Formatted as "key:val;key:val"
|
-p, --properties <props> Specify properties. Formatted as "key:val;key:val"
|
||||||
When used with the list command this option applies
|
When used with the list command this option applies
|
||||||
a filter to the issues listed, only allowing those
|
a filter to the issues listed, only allowing those
|
||||||
which have all of the given properties.
|
which have all of the given properties.
|
||||||
|
|
||||||
|
-c, --context <ctxName> Shorthand for '-p context:<ctxName>'
|
||||||
|
|
||||||
|
-g, --tags <tags> Specify tags for an issue.
|
||||||
|
|
||||||
-T, --today Limit to today's issues.
|
-T, --today Limit to today's issues.
|
||||||
|
|
||||||
-F, --future Limit to future issues.
|
-F, --future Limit to future issues.
|
||||||
|
|
||||||
|
-v, --verbose Show issue details when listing issues.
|
||||||
|
|
||||||
|
-q, --quiet Suppress verbose output.
|
||||||
|
|
||||||
|
-y, --yes Automatically answer "yes" to any prompts.
|
||||||
|
|
||||||
-C, --config <cfgFile> Location of the config file (defaults to $HOME/.pitrc)
|
-C, --config <cfgFile> Location of the config file (defaults to $HOME/.pitrc)
|
||||||
|
|
||||||
-E, --echo-args Echo arguments (for debug purposes).
|
-E, --echo-args Echo arguments (for debug purposes).
|
||||||
|
|
||||||
--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 Manually set the terminal width to use.
|
--term-width <width> Manually set the terminal width to use.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
logging.addHandler(newConsoleLogger())
|
logging.addHandler(newConsoleLogger())
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
let args = docopt(doc, version = "pit 4.0.0")
|
let args = docopt(doc, version = PIT_VERSION)
|
||||||
|
|
||||||
if args["--echo-args"]: stderr.writeLine($args)
|
if args["--echo-args"]: stderr.writeLine($args)
|
||||||
|
|
||||||
@ -268,41 +275,67 @@ Options:
|
|||||||
|
|
||||||
let ctx = initContext(args)
|
let ctx = initContext(args)
|
||||||
|
|
||||||
|
var propertiesOption = none(TableRef[string,string])
|
||||||
|
var tagsOption = none(seq[string])
|
||||||
|
|
||||||
|
if args["--properties"] or args["--context"]:
|
||||||
|
|
||||||
|
var props =
|
||||||
|
if args["--properties"]: parsePropertiesOption($args["--properties"])
|
||||||
|
else: newTable[string,string]()
|
||||||
|
|
||||||
|
if args["--context"]: props["context"] = $args["--context"]
|
||||||
|
|
||||||
|
propertiesOption = some(props)
|
||||||
|
|
||||||
|
if args["--tags"]: tagsOption = some(($args["--tags"]).split(",").mapIt(it.strip))
|
||||||
|
|
||||||
## Actual command runners
|
## Actual command runners
|
||||||
if args["new"]:
|
if args["new"] or args["add"]:
|
||||||
let state =
|
let state =
|
||||||
if args["<state>"]: parseEnum[IssueState]($args["<state>"])
|
if args["<state>"]: parseEnum[IssueState]($args["<state>"])
|
||||||
else: TodoToday
|
else: TodoToday
|
||||||
|
|
||||||
|
var issueProps = propertiesOption.get(newTable[string,string]())
|
||||||
|
if not issueProps.hasKey("created"): issueProps["created"] = getTime().local.formatIso8601
|
||||||
|
if not issueProps.hasKey("context") and ctx.defaultContext.isSome():
|
||||||
|
stderr.writeLine("Using default context: " & ctx.defaultContext.get)
|
||||||
|
issueProps["context"] = ctx.defaultContext.get
|
||||||
|
|
||||||
var issue = Issue(
|
var issue = Issue(
|
||||||
id: genUUID(),
|
id: genUUID(),
|
||||||
summary: $args["<summary>"],
|
summary: $args["<summary>"],
|
||||||
properties:
|
properties: issueProps,
|
||||||
if args["--properties"]: parsePropertiesOption($args["--properties"])
|
|
||||||
else: newTable[string,string](),
|
|
||||||
tags:
|
tags:
|
||||||
if args["--tags"]: ($args["tags"]).split(",").mapIt(it.strip)
|
if args["--tags"]: ($args["--tags"]).split(",").mapIt(it.strip)
|
||||||
else: newSeq[string]())
|
else: newSeq[string]())
|
||||||
|
|
||||||
ctx.tasksDir.store(issue, state)
|
ctx.tasksDir.store(issue, state)
|
||||||
|
|
||||||
|
stdout.writeLine ctx.formatIssue(issue)
|
||||||
|
|
||||||
elif args["edit"]:
|
elif args["edit"]:
|
||||||
let issueId = $args["<id>"]
|
for id in @(args["<id>"]):
|
||||||
|
edit(ctx.tasksDir.loadIssueById(id))
|
||||||
|
|
||||||
edit(ctx.tasksDir.loadIssueById(issueId))
|
elif args["start"] or args["todo-today"] or args["done"] or
|
||||||
|
args["pending"] or args["todo"] or args["suspend"]:
|
||||||
elif args["start"] or args["do-today"] or args["done"] or
|
|
||||||
args["pending"] or args["todo"]:
|
|
||||||
|
|
||||||
var targetState: IssueState
|
var targetState: IssueState
|
||||||
if args["done"]: targetState = Done
|
if args["done"]: targetState = Done
|
||||||
elif args["do-today"]: targetState = TodoToday
|
elif args["todo-today"]: targetState = TodoToday
|
||||||
elif args["pending"]: targetState = Todo
|
elif args["pending"]: targetState = Pending
|
||||||
elif args["start"]: targetState = Current
|
elif args["start"]: targetState = Current
|
||||||
elif args["todo"]: targetState = Todo
|
elif args["todo"]: targetState = Todo
|
||||||
|
elif args["suspend"]: targetState = Dormant
|
||||||
|
|
||||||
for id in @(args["<id>"]):
|
for id in @(args["<id>"]):
|
||||||
ctx.tasksDir.moveIssue(ctx.tasksDir.loadIssueById(id), targetState)
|
var issue = ctx.tasksDir.loadIssueById(id)
|
||||||
|
if propertiesOption.isSome:
|
||||||
|
for k,v in propertiesOption.get:
|
||||||
|
issue[k] = v
|
||||||
|
if targetState == Done: issue["completed"] = getTime().local.formatIso8601
|
||||||
|
issue.changeState(ctx.tasksDir, targetState)
|
||||||
|
|
||||||
if ctx.triggerPtk:
|
if ctx.triggerPtk:
|
||||||
if targetState == Current:
|
if targetState == Current:
|
||||||
@ -311,27 +344,75 @@ Options:
|
|||||||
if issue.tags.len > 0: cmd &= "-g \"" & issue.tags.join(",") & "\""
|
if issue.tags.len > 0: cmd &= "-g \"" & issue.tags.join(",") & "\""
|
||||||
cmd &= " \"" & issue.summary & "\""
|
cmd &= " \"" & issue.summary & "\""
|
||||||
discard execShellCmd(cmd)
|
discard execShellCmd(cmd)
|
||||||
elif targetState == Done: discard execShellCmd("ptk stop")
|
elif targetState == Done or targetState == Pending:
|
||||||
|
discard execShellCmd("ptk stop")
|
||||||
|
|
||||||
|
elif args["delete"] or args["rm"]:
|
||||||
|
for id in @(args["<id>"]):
|
||||||
|
|
||||||
|
let issue = ctx.tasksDir.loadIssueById(id)
|
||||||
|
|
||||||
|
if not args["--yes"]:
|
||||||
|
stderr.write("Delete '" & issue.summary & "' (y/n)? ")
|
||||||
|
if not "yes".startsWith(stdin.readLine.toLower):
|
||||||
|
continue
|
||||||
|
|
||||||
|
issue.delete
|
||||||
|
|
||||||
elif args["list"]:
|
elif args["list"]:
|
||||||
|
|
||||||
let filter = initFilter()
|
let filter = initFilter()
|
||||||
var filterOption = none(IssueFilter)
|
var filterOption = none(IssueFilter)
|
||||||
if args["--properties"]:
|
|
||||||
filter.properties = parsePropertiesOption($args["--properties"])
|
# Initialize filter with properties (if given)
|
||||||
|
if propertiesOption.isSome:
|
||||||
|
filter.properties = propertiesOption.get
|
||||||
filterOption = some(filter)
|
filterOption = some(filter)
|
||||||
|
|
||||||
let stateOption =
|
# If no "context" property is given, use the default (if we have one)
|
||||||
if args["<state>"]: some(parseEnum[IssueState]($args["<state>"]))
|
if ctx.defaultContext.isSome and not filter.properties.hasKey("context"):
|
||||||
else: none(IssueState)
|
stderr.writeLine("Limiting to default context: " & ctx.defaultContext.get)
|
||||||
|
filter.properties["context"] = ctx.defaultContext.get
|
||||||
|
filterOption = some(filter)
|
||||||
|
|
||||||
|
# Finally, if the "context" is "all", don't filter on context
|
||||||
|
if filter.properties.hasKey("context") and
|
||||||
|
filter.properties["context"] == "all":
|
||||||
|
|
||||||
|
filter.properties.del("context")
|
||||||
|
|
||||||
|
var listContexts = false
|
||||||
|
var stateOption = none(IssueState)
|
||||||
|
var issueIdOption = none(string)
|
||||||
|
|
||||||
|
if args["<listable>"]:
|
||||||
|
if $args["<listable>"] == "contexts": listContexts = true
|
||||||
|
else:
|
||||||
|
try: stateOption = some(parseEnum[IssueState]($args["<listable>"]))
|
||||||
|
except: issueIdOption = some($args["<listable>"])
|
||||||
|
|
||||||
|
# List the known contexts
|
||||||
|
if listContexts:
|
||||||
|
var uniqContexts = toSeq(ctx.contexts.keys)
|
||||||
|
ctx.loadAllIssues()
|
||||||
|
for state, issueList in ctx.issues:
|
||||||
|
for issue in issueList:
|
||||||
|
if issue.hasProp("context") and not uniqContexts.contains(issue["context"]):
|
||||||
|
uniqContexts.add(issue["context"])
|
||||||
|
|
||||||
|
for c in uniqContexts: stdout.writeLine(c)
|
||||||
|
|
||||||
|
# List a specific issue
|
||||||
|
elif issueIdOption.isSome:
|
||||||
|
let issue = ctx.tasksDir.loadIssueById(issueIdOption.get)
|
||||||
|
stdout.writeLine ctx.formatIssue(issue)
|
||||||
|
|
||||||
|
# List all issues
|
||||||
|
else:
|
||||||
let showBoth = args["--today"] == args["--future"]
|
let showBoth = args["--today"] == args["--future"]
|
||||||
ctx.list(filterOption, stateOption, showBoth or args["--today"],
|
ctx.list(filterOption, stateOption, showBoth or args["--today"],
|
||||||
showBoth or args["--future"])
|
showBoth or args["--future"],
|
||||||
|
ctx.verbose)
|
||||||
if ctx.autoList and not args["list"]:
|
|
||||||
ctx.loadAllIssues()
|
|
||||||
ctx.list(none(IssueFilter), none(IssueState), true, true)
|
|
||||||
|
|
||||||
except:
|
except:
|
||||||
fatal "pit: " & getCurrentExceptionMsg()
|
fatal "pit: " & getCurrentExceptionMsg()
|
||||||
|
132
src/pit_api.nim
Normal file
132
src/pit_api.nim
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
## Personal Issue Tracker API Interface
|
||||||
|
## ====================================
|
||||||
|
|
||||||
|
import asyncdispatch, cliutils, docopt, jester, json, logging, sequtils, strutils
|
||||||
|
import nre except toSeq
|
||||||
|
|
||||||
|
import pitpkg/private/libpit
|
||||||
|
|
||||||
|
include "pitpkg/private/version.nim"
|
||||||
|
|
||||||
|
type
|
||||||
|
PitApiCfg* = object
|
||||||
|
apiKeys*: seq[string]
|
||||||
|
global*: PitConfig
|
||||||
|
port*: int
|
||||||
|
|
||||||
|
const TXT = "text/plain"
|
||||||
|
|
||||||
|
proc raiseEx(reason: string): void = raise newException(Exception, reason)
|
||||||
|
|
||||||
|
template checkAuth(cfg: PitApiCfg) =
|
||||||
|
## Check this request for authentication and authorization information.
|
||||||
|
## If the request is not authorized, this template sets up the 401 response
|
||||||
|
## correctly. The calling context needs only to return from the route.
|
||||||
|
|
||||||
|
var authed {.inject.} = false
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not request.headers.hasKey("Authorization"):
|
||||||
|
raiseEx "No auth token."
|
||||||
|
|
||||||
|
let headerVal = request.headers["Authorization"]
|
||||||
|
if not headerVal.startsWith("Bearer "):
|
||||||
|
raiseEx "Invalid Authentication type (only 'Bearer' is supported)."
|
||||||
|
|
||||||
|
if not cfg.apiKeys.contains(headerVal[7..^1]):
|
||||||
|
raiseEx "Invalid API key."
|
||||||
|
|
||||||
|
authed = true
|
||||||
|
|
||||||
|
except:
|
||||||
|
stderr.writeLine "Auth failed: " & getCurrentExceptionMsg()
|
||||||
|
response.data[0] = CallbackAction.TCActionSend
|
||||||
|
response.data[1] = Http401
|
||||||
|
response.data[2]["WWW-Authenticate"] = "Bearer"
|
||||||
|
response.data[2]["Content-Type"] = TXT
|
||||||
|
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) =
|
||||||
|
|
||||||
|
var stopFuture = newFuture[void]()
|
||||||
|
|
||||||
|
settings:
|
||||||
|
port = Port(cfg.port)
|
||||||
|
appName = "/api"
|
||||||
|
|
||||||
|
routes:
|
||||||
|
|
||||||
|
get "/ping":
|
||||||
|
resp("pong", TXT)
|
||||||
|
|
||||||
|
get "/issues":
|
||||||
|
checkAuth(cfg); if not authed: return true
|
||||||
|
|
||||||
|
var (stripAnsi, args) = paramsToArgs(request.params)
|
||||||
|
args = @["list"] & 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:
|
||||||
|
if stripAnsi: resp(stripAnsi(execResult[0]), TXT)
|
||||||
|
else: resp(execResult[0], TXT)
|
||||||
|
|
||||||
|
post "/issues":
|
||||||
|
checkAuth(cfg); if not authed: return true
|
||||||
|
|
||||||
|
waitFor(stopFuture)
|
||||||
|
|
||||||
|
proc loadApiConfig(args: Table[string, Value]): PitApiCfg =
|
||||||
|
let pitCfg = loadConfig(args)
|
||||||
|
let apiJson =
|
||||||
|
if pitCfg.cfg.json.hasKey("api"): pitCfg.cfg.json["api"]
|
||||||
|
else: newJObject()
|
||||||
|
|
||||||
|
let apiCfg = CombinedConfig(docopt: args, json: apiJson)
|
||||||
|
|
||||||
|
let apiKeysArray =
|
||||||
|
if apiJson.hasKey("apiKeys"): apiJson["apiKeys"]
|
||||||
|
else: newJArray()
|
||||||
|
|
||||||
|
result = PitApiCfg(
|
||||||
|
apiKeys: toSeq(apiKeysArray).mapIt(it.getStr),
|
||||||
|
global: pitCfg,
|
||||||
|
port: parseInt(apiCfg.getVal("port", "8123")))
|
||||||
|
|
||||||
|
when isMainModule:
|
||||||
|
|
||||||
|
let doc = """\
|
||||||
|
Usage:
|
||||||
|
pit_api [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
-c, --config <cfgFile> Path to the pit_api config file.
|
||||||
|
-d, --tasks-dir Path to the tasks directory.
|
||||||
|
-p, --port Port to listen on (defaults to 8123)
|
||||||
|
"""
|
||||||
|
|
||||||
|
let args = docopt(doc, version = PIT_VERSION)
|
||||||
|
|
||||||
|
let apiCfg = loadApiConfig(args)
|
||||||
|
start(apiCfg)
|
@ -1,4 +1,5 @@
|
|||||||
import cliutils, options, os, ospaths, sequtils, strutils, tables, times, timeutils, uuids
|
import cliutils, docopt, json, logging, options, os, ospaths, sequtils,
|
||||||
|
strutils, tables, times, timeutils, uuids
|
||||||
|
|
||||||
from nre import re, match
|
from nre import re, match
|
||||||
type
|
type
|
||||||
@ -15,11 +16,17 @@ type
|
|||||||
Pending = "pending",
|
Pending = "pending",
|
||||||
Done = "done",
|
Done = "done",
|
||||||
Todo = "todo"
|
Todo = "todo"
|
||||||
|
Dormant = "dormant"
|
||||||
|
|
||||||
IssueFilter* = ref object
|
IssueFilter* = ref object
|
||||||
properties*: TableRef[string, string]
|
properties*: TableRef[string, string]
|
||||||
completedRange*: tuple[b, e: DateTime]
|
completedRange*: tuple[b, e: DateTime]
|
||||||
|
|
||||||
|
PitConfig* = ref object
|
||||||
|
tasksDir*: string
|
||||||
|
contexts*: TableRef[string, string]
|
||||||
|
cfg*: CombinedConfig
|
||||||
|
|
||||||
const DONE_FOLDER_FORMAT* = "yyyy-MM"
|
const DONE_FOLDER_FORMAT* = "yyyy-MM"
|
||||||
|
|
||||||
let ISSUE_FILE_PATTERN = re"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}\.txt"
|
let ISSUE_FILE_PATTERN = re"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}\.txt"
|
||||||
@ -27,8 +34,9 @@ let ISSUE_FILE_PATTERN = re"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f
|
|||||||
proc displayName*(s: IssueState): string =
|
proc displayName*(s: IssueState): string =
|
||||||
case s
|
case s
|
||||||
of Current: result = "Current"
|
of Current: result = "Current"
|
||||||
of Pending: result = "Pending"
|
|
||||||
of Done: result = "Done"
|
of Done: result = "Done"
|
||||||
|
of Dormant: result = "Dormant"
|
||||||
|
of Pending: result = "Pending"
|
||||||
of Todo: result = "Todo"
|
of Todo: result = "Todo"
|
||||||
of TodoToday: result = "Todo"
|
of TodoToday: result = "Todo"
|
||||||
|
|
||||||
@ -160,10 +168,13 @@ proc loadIssues*(path: string): seq[Issue] =
|
|||||||
if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome():
|
if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome():
|
||||||
result.add(loadIssue(path))
|
result.add(loadIssue(path))
|
||||||
|
|
||||||
proc moveIssue*(tasksDir: string, issue: Issue, newState: IssueState) =
|
proc changeState*(issue: Issue, tasksDir: string, newState: IssueState) =
|
||||||
removeFile(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)
|
||||||
|
|
||||||
|
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]] =
|
proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] =
|
||||||
@ -184,3 +195,52 @@ proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
|
|||||||
filter.completedRange.b,
|
filter.completedRange.b,
|
||||||
filter.completedRange.e))
|
filter.completedRange.e))
|
||||||
|
|
||||||
|
### Configuration utilities
|
||||||
|
proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitConfig =
|
||||||
|
let pitrcLocations = @[
|
||||||
|
if args["--config"]: $args["--config"] else: "",
|
||||||
|
".pitrc", $getEnv("PITRC"), $getEnv("HOME") & "/.pitrc"]
|
||||||
|
|
||||||
|
var pitrcFilename: string =
|
||||||
|
foldl(pitrcLocations, if len(a) > 0: a elif existsFile(b): b else: "")
|
||||||
|
|
||||||
|
if not existsFile(pitrcFilename):
|
||||||
|
warn "pit: could not find .pitrc file: " & pitrcFilename
|
||||||
|
if isNilOrWhitespace(pitrcFilename):
|
||||||
|
pitrcFilename = $getEnv("HOME") & "/.pitrc"
|
||||||
|
var cfgFile: File
|
||||||
|
try:
|
||||||
|
cfgFile = open(pitrcFilename, fmWrite)
|
||||||
|
cfgFile.write("{\"tasksDir\": \"/path/to/tasks\"}")
|
||||||
|
except: warn "pit: could not write default .pitrc to " & pitrcFilename
|
||||||
|
finally: close(cfgFile)
|
||||||
|
|
||||||
|
var cfgJson: JsonNode
|
||||||
|
try: cfgJson = parseFile(pitrcFilename)
|
||||||
|
except: raise newException(IOError,
|
||||||
|
"unable to read config file: " & pitrcFilename &
|
||||||
|
"\x0D\x0A" & getCurrentExceptionMsg())
|
||||||
|
|
||||||
|
let cfg = CombinedConfig(docopt: args, json: cfgJson)
|
||||||
|
|
||||||
|
result = PitConfig(
|
||||||
|
cfg: cfg,
|
||||||
|
contexts: newTable[string,string](),
|
||||||
|
tasksDir: cfg.getVal("tasks-dir", ""))
|
||||||
|
|
||||||
|
if cfgJson.hasKey("contexts"):
|
||||||
|
for k, v in cfgJson["contexts"]:
|
||||||
|
result.contexts[k] = v.getStr()
|
||||||
|
|
||||||
|
if isNilOrWhitespace(result.tasksDir):
|
||||||
|
raise newException(Exception, "no tasks directory configured")
|
||||||
|
|
||||||
|
if not existsDir(result.tasksDir):
|
||||||
|
raise newException(Exception, "cannot find tasks dir: " & result.tasksDir)
|
||||||
|
|
||||||
|
# Create our tasks directory structure if needed
|
||||||
|
for s in IssueState:
|
||||||
|
if not existsDir(result.tasksDir / $s):
|
||||||
|
(result.tasksDir / $s).createDir
|
||||||
|
|
||||||
|
|
1
src/pitpkg/private/version.nim
Normal file
1
src/pitpkg/private/version.nim
Normal file
@ -0,0 +1 @@
|
|||||||
|
const PIT_VERSION = "4.2.0"
|
Reference in New Issue
Block a user