diff --git a/.gitignore b/.gitignore index e6b05d8..4e75716 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.sw* nimcache/ /pit +/pit_api diff --git a/pit.nimble b/pit.nimble index 69a1780..00f6330 100644 --- a/pit.nimble +++ b/pit.nimble @@ -1,12 +1,15 @@ # Package -version = "4.0.7" +include "src/pitpkg/private/version.nim" + +version = PIT_VERSION author = "Jonathan Bernard" description = "Personal issue tracker." license = "MIT" srcDir = "src" -bin = @["pit"] +bin = @["pit", "pit_api"] # 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.3.5", "docopt 0.6.5", "jester 0.2.0", + "timeutils 0.3.0", "uuids 0.1.9" ] diff --git a/src/pit.nim b/src/pit.nim index b0e497e..897ca74 100644 --- a/src/pit.nim +++ b/src/pit.nim @@ -1,6 +1,5 @@ -## Personal Issue Tracker -## ====================== -## +## Personal Issue Tracker CLI interface +## ==================================== import cliutils, docopt, json, logging, options, os, ospaths, sequtils, tables, terminal, times, timeutils, unicode, uuids @@ -9,58 +8,35 @@ import strutils except capitalize, toUpper, toLower import pitpkg/private/libpit export libpit +include "pitpkg/private/version.nim" + type CliContext = ref object - autoList, triggerPtk: bool - tasksDir*: string + autoList, triggerPtk, verbose: bool + cfg*: PitConfig contexts*: TableRef[string, string] + defaultContext*, tasksDir*: string issues*: TableRef[IssueState, seq[Issue]] termWidth*: int proc initContext(args: Table[string, Value]): CliContext = - let pitrcLocations = @[ - if args["--config"]: $args["--config"] else: "", - ".pitrc", $getEnv("PITRC"), $getEnv("HOME") & "/.pitrc"] + let pitCfg = loadConfig(args) - var pitrcFilename: string = - foldl(pitrcLocations, if len(a) > 0: a elif existsFile(b): b else: "") + let cliJson = + if pitCfg.cfg.json.hasKey("cli"): pitCfg.cfg.json["cli"] + else: newJObject() - 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) + let cliCfg = CombinedConfig(docopt: args, json: cliJson) result = CliContext( - autoList: cfgJson.getOrDefault("autoList").getBool(false), - contexts: newTable[string,string](), + autoList: cliJson.getOrDefault("autoList").getBool(false), + contexts: pitCfg.contexts, + defaultContext: cliJson.getOrDefault("defaultContext").getStr(""), + verbose: parseBool(cliCfg.getVal("verbose", "false")) and not args["--quiet"], issues: newTable[IssueState, seq[Issue]](), - tasksDir: cfg.getVal("tasks-dir", ""), - termWidth: parseInt(cfg.getVal("term-width", "80")), - triggerPtk: cfgJson.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) + tasksDir: pitCfg.tasksDir, + termWidth: parseInt(cliCfg.getVal("term-width", "80")), + triggerPtk: cliJson.getOrDefault("triggerPtk").getBool(false)) proc getIssueContextDisplayName(ctx: CliContext, context: string): string = if not ctx.contexts.hasKey(context): @@ -79,7 +55,7 @@ proc writeIssue(ctx: CliContext, issue: Issue, width: int, indent = "", wrappedSummary = wrappedSummary[(6 + indent.len)..^1] stdout.setForegroundColor(fgBlack, true) stdout.write(indent & ($issue.id)[0..<6]) - stdout.setForegroundColor(fgCyan, false) + stdout.setForegroundColor(fgWhite, false) stdout.write(wrappedSummary) if issue.tags.len > 0: @@ -90,17 +66,20 @@ proc writeIssue(ctx: CliContext, issue: Issue, width: int, indent = "", else: stdout.writeLine("\n" & indent & " " & tagsStr) else: stdout.writeLine("") - stdout.resetAttributes if issue.hasProp("pending"): let startIdx = "Pending: ".len var pendingText = issue["pending"].wordWrap(width - startIdx - 2) .indent(startIdx) pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2) + stdout.setForegroundColor(fgCyan, false) stdout.writeLine(pendingText) - if showDetails: stdout.writeLine(issue.details.indent(indent.len + 2)) + if showDetails: + stdout.setForegroundColor(fgCyan, false) + stdout.writeLine(issue.details.indent(indent.len + 2)) + stdout.resetAttributes proc writeSection(ctx: CliContext, issues: seq[Issue], state: IssueState, indent = "", verbose = false) = @@ -228,7 +207,7 @@ when isMainModule: Usage: pit ( new | add) [] [options] pit list [] [options] - pit ( start | done | pending | do-today | todo | suspend ) ... + pit ( start | done | pending | do-today | todo | suspend ) ... [options] pit edit pit ( delete | rm ) ... @@ -243,7 +222,7 @@ Options: -c, --context Shorthand for '-p context:' - -t, --tags Specify tags for an issue. + -g, --tags Specify tags for an issue. -T, --today Limit to today's issues. @@ -251,22 +230,24 @@ Options: -v, --verbose Show issue details when listing issues. + -q, --quiet Suppress verbose output. + -y, --yes Automatically answer "yes" to any prompts. -C, --config Location of the config file (defaults to $HOME/.pitrc) -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) - --term-width Manually set the terminal width to use. + --term-width Manually set the terminal width to use. """ logging.addHandler(newConsoleLogger()) # Parse arguments - let args = docopt(doc, version = "pit 4.0.7") + let args = docopt(doc, version = PIT_VERSION) if args["--echo-args"]: stderr.writeLine($args) @@ -276,20 +257,21 @@ Options: let ctx = initContext(args) - # Create our tasks directory structure if needed - for s in IssueState: - if not existsDir(ctx.tasksDir / $s): - (ctx.tasksDir / $s).createDir - var propertiesOption = none(TableRef[string,string]) - if args["--properties"] or args["--context"]: + if args["--properties"] or args["--context"] or + not ctx.defaultContext.isNilOrWhitespace: var props = if args["--properties"]: parsePropertiesOption($args["--properties"]) else: newTable[string,string]() - if args["--context"]: props["context"] = $args["--context"] + if args["--context"] and $args["--context"] != "all": + props["context"] = $args["--context"] + elif not args["--context"] and not ctx.defaultContext.isNilOrWhitespace: + stderr.writeLine("Limiting to default context: " & ctx.defaultContext) + props["context"] = ctx.defaultContext + propertiesOption = some(props) ## Actual command runners @@ -306,7 +288,7 @@ Options: summary: $args[""], properties: issueProps, tags: - if args["--tags"]: ($args["tags"]).split(",").mapIt(it.strip) + if args["--tags"]: ($args["--tags"]).split(",").mapIt(it.strip) else: newSeq[string]()) ctx.tasksDir.store(issue, state) @@ -322,13 +304,17 @@ Options: var targetState: IssueState if args["done"]: targetState = Done elif args["do-today"]: targetState = TodoToday - elif args["pending"]: targetState = Todo + elif args["pending"]: targetState = Pending elif args["start"]: targetState = Current elif args["todo"]: targetState = Todo elif args["suspend"]: targetState = Dormant for id in @(args[""]): - ctx.tasksDir.loadIssueById(id).changeState(ctx.tasksDir, targetState) + var issue = ctx.tasksDir.loadIssueById(id) + if propertiesOption.isSome: + for k,v in propertiesOption.get: + issue[k] = v + issue.changeState(ctx.tasksDir, targetState) if ctx.triggerPtk: if targetState == Current: @@ -337,7 +323,8 @@ Options: if issue.tags.len > 0: cmd &= "-g \"" & issue.tags.join(",") & "\"" cmd &= " \"" & issue.summary & "\"" 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[""]): @@ -375,7 +362,7 @@ Options: let showBoth = args["--today"] == args["--future"] ctx.list(filterOption, stateOption, showBoth or args["--today"], showBoth or args["--future"], - args["--verbose"]) + ctx.verbose) if ctx.autoList and not args["list"]: ctx.loadAllIssues() diff --git a/src/pit_api.nim b/src/pit_api.nim new file mode 100644 index 0000000..8235fad --- /dev/null +++ b/src/pit_api.nim @@ -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 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) diff --git a/src/pitpkg/private/libpit.nim b/src/pitpkg/private/libpit.nim index 029aab2..148bdd1 100644 --- a/src/pitpkg/private/libpit.nim +++ b/src/pitpkg/private/libpit.nim @@ -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 type @@ -21,6 +22,11 @@ type properties*: TableRef[string, string] completedRange*: tuple[b, e: DateTime] + PitConfig* = ref object + tasksDir*: string + contexts*: TableRef[string, string] + cfg*: CombinedConfig + 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" @@ -189,3 +195,52 @@ proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] = filter.completedRange.b, 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 + + diff --git a/src/pitpkg/private/version.nim b/src/pitpkg/private/version.nim new file mode 100644 index 0000000..4b5feec --- /dev/null +++ b/src/pitpkg/private/version.nim @@ -0,0 +1 @@ +const PIT_VERSION = "4.1.0"