Compare commits

..

10 Commits
4.0.0 ... 4.3.0

Author SHA1 Message Date
2404f6a3d1 Add the ability to edit all issues in a given state. 2018-06-06 09:43:31 -05:00
2b5f82203c Add list contexts, refactor display logics.
* Refactor formatting logic to better calculate needed padding between
  issues and sections.
* Add `list contexts` command to list all known contexts according to
  the contexts configuration and the contexts defined in issues.
* Be more intentional about when the default context is used. Don't
  override existing context values in issues when changing their state.
* `edit` now allows multiple issues to be edited.
* Change single-issue display to be more verbose, listing all the
  properties and tags on an issue.
2018-05-29 14:24:18 -05:00
29959a6a8d Add REST API. Refactor config logic.
The REST API is simply a wrapper around the command line (and actually
invokes the command line). It relies on the command line tool validating
its input.

Currently only the `/list` endpoint is implemented, exposing the `list`
command.
2018-05-18 16:06:58 -05:00
6f247032a3 Add created property when creating issues. 2018-05-14 17:17:47 -05:00
efd5f6adff Add versbose flag, list specific issue. 2018-05-14 12:21:05 -05:00
49c5753ef1 Add rm as an alias for delete. 2018-05-14 10:09:33 -05:00
3bdb2ecb1f Fix padding issue in context listing. 2018-05-14 10:04:24 -05:00
28569a643e Added Dormant state, auto-create task dirs.
The Dormant state is for tasks that are still outstanding but not of
immediate importance. The main different between Dormant and Todo is
that dormant tasks are not listed by default. You must
`pit list dormant` to see them.
2018-05-14 09:53:15 -05:00
97eb286e32 Reorganize project folder structure to work according to nimble package expectations. 2018-05-14 09:36:23 -05:00
fcab7a4cc6 Added add (alias for new) and delete. 2018-05-14 09:28:50 -05:00
6 changed files with 422 additions and 133 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
*.sw* *.sw*
nimcache/ nimcache/
/pit /pit
/pit_api

View File

@ -1,12 +1,15 @@
# Package # Package
version = "4.0.0" include "src/pitpkg/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" ]

View File

@ -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/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 <ref>...
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,78 @@ 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 editRef in @(args["<ref>"]):
edit(ctx.tasksDir.loadIssueById(issueId)) var stateOption = none(IssueState)
elif args["start"] or args["do-today"] or args["done"] or try: stateOption = some(parseEnum[IssueState](editRef))
args["pending"] or args["todo"]: except: discard
if stateOption.isSome:
let state = stateOption.get
ctx.loadIssues(state)
for issue in ctx.issues[state]: edit(issue)
else: edit(ctx.tasksDir.loadIssueById(editRef))
elif args["start"] or args["todo-today"] or args["done"] or
args["pending"] or args["todo"] or args["suspend"]:
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 +355,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
View 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/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)

View File

@ -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/version.nim Normal file
View File

@ -0,0 +1 @@
const PIT_VERSION = "4.3.0"