6 Commits

6 changed files with 393 additions and 143 deletions

239
private/api.nim Normal file
View File

@ -0,0 +1,239 @@
## Personal Time Keeping API Interface
## ===================================
import asyncdispatch, base64, bcrypt, cliutils, docopt, jester, json, logging,
ospaths, sequtils, strutils, os, times, uuids
import nre except toSeq
import ./models
import ./util
import ./version
type
PtkUser* = object
username*, salt*, pwdhash*, timelinePath*: string
isAdmin*: bool
PtkApiCfg* = object
users*: seq[PtkUser]
port*: int
dataDir*: string
const TXT = "text/plain"
const JSON = "application/json"
proc parseUser(json: JsonNode): PtkUser =
result = Ptkuser(
username: json.getOrFail("username").getStr,
salt: json.getOrFail("salt").getStr,
pwdHash: json.getOrFail("pwdhash").getStr,
timelinePath: json.getOrFail("timelinePath").getStr,
isAdmin: json.getIfExists("isAdmin").getBool(false))
proc loadApiConfig*(json: JsonNode): PtkApiCfg =
result = PtkApiCfg(
port: parseInt(json.getIfExists("port").getStr("3280")),
dataDir: json.getOrFail("dataDir").getStr,
users: json.getIfExists("users").getElems(@[]).mapIt(parseUser(it)))
template checkAuth(cfg: PtkApiCfg) =
## 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
var user {.inject.}: PtkUser = PtkUser()
try:
if not request.headers.hasKey("Authorization"):
raiseEx "No auth token."
let headerVal = request.headers["Authorization"]
if not headerVal.startsWith("Basic "):
raiseEx "Invalid Authorization type (only 'Basic' is supported)."
let authVals = headerVal[6..^1].decode().split(":")
let candidates = cfg.users.filterIt(it.username.compare(authVals[0]))
if candidates.len != 1: raiseEx "Invalid Authorization: unknown username/password combination."
let foundUser: PtkUser = candidates[0]
if not compare(foundUser.pwdhash, hash(authVals[1], foundUser.salt)):
raiseEx "Invalid Authorization: unknown username/password combination."
user = foundUser
authed = true
except:
stderr.writeLine "Auth failed: " & getCurrentExceptionMsg()
response.data[0] = CallbackAction.TCActionSend
response.data[1] = Http401
response.data[2]["WWW_Authenticate"] = "Basic"
response.data[2]["Content-Type"] = TXT
response.data[3] = getCurrentExceptionMsg()
proc parseAndRun(user: PtkUser, cmd: string, params: StringTableRef): string =
var args = queryParamsToCliArgs(params)
args = @[cmd, "--file", user.timelinePath] & args
info "args: \n" & args.join(" ")
let execResult = execWithOutput("ptk", ".", args)
if execResult[2] != 0: raiseEx(stripAnsi($execResult[0] & "\n" & $execResult[1]))
else: return stripAnsi(execResult[0])
proc apiParseUser(json: JsonNode): PtkUser =
let salt = genSalt(12)
return PtkUser(
username: json.getOrFail("username").getStr,
pwdhash: json.getOrFail("password").getStr.hash(salt),
salt: salt,
timelinePath: json.getIfExists("timelinePath").getStr(""),
isAdmin: false)
proc apiParseMark(json: JsonNode): Mark =
if not json.hasKey("id"): json["id"] = %($genUUID())
if not json.hasKey("summary"): raiseEx "cannot parse mark: missing 'summary'"
if not json.hasKey("time"): json["time"] = %(getTime().local.format(ISO_TIME_FORMAT))
return parseMark(json)
proc patchMark(m: Mark, j: JsonNode): Mark =
result = m
if j.hasKey("summary"): result.summary = j["summary"].getStr
if j.hasKey("notes"): result.notes = j["notes"].getStr
if j.hasKey("tags"):
result.tags = j["tags"].getElems(@[]).map(proc (t: JsonNode): string = t.getStr())
if j.hasKey("time"): result.time = parse(j["time"].getStr(), ISO_TIME_FORMAT)
proc start_api*(cfg: PtkApiCfg) =
var stopFuture = newFuture[void]()
settings:
port = Port(cfg.port)
appName = "/api"
routes:
get "/version": resp("ptk v" & PTK_VERSION, TXT)
get "/marks":
checkAuth(cfg); if not authed: return true
try: resp(parseAndRun(user, "list", request.params), TXT)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/continue":
checkAuth(cfg); if not authed: return true
try: resp(parseAndRun(user, "continue", request.params), TXT)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/sum-time":
checkAuth(cfg); if not authed: return true
try: resp(parseAndRun(user, "sum-time", request.params), TXT)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/mark":
checkAuth(cfg); if not authed: return true
var newMark: Mark
try: newMark = apiParseMark(parseJson(request.body))
except: resp(Http400, getCurrentExceptionMsg(), TXT)
try:
var timeline = loadTimeline(user.timelinePath)
timeline.marks.add(newMark)
saveTimeline(timeline, user.timelinePath)
resp(Http201, "ok", TXT)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/stop":
checkAuth(cfg); if not authed: return true
var newMark: Mark
try:
var json = parseJson(request.body)
json["summary"] = %STOP_MSG
json["id"] = %($genUUID())
newMark = apiParseMark(json)
except: resp(Http400, getCurrentExceptionMsg(), TXT)
try:
var timeline = loadTimeline(user.timelinePath)
timeline.marks.add(newMark)
saveTimeline(timeline, user.timelinePath)
resp(Http201, "ok", TXT)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/resume/@id":
checkAuth(cfg); if not authed: return true
var timeline: Timeline
try: timeline = loadTimeline(user.timelinePath)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
var newMark: Mark
try:
let origMarkIdx = timeline.marks.findById(@"id")
if origMarkIdx < 0: resp(Http404, "no mark for id: " & @"id", TXT)
let origMark = timeline.marks[origMarkIdx]
newMark = origMark
newMark.id = genUUID()
newMark.time = getTime().local
newMark = newMark.patchMark(parseJson(request.body))
except: resp(Http400, getCurrentExceptionMsg(), TXT)
try:
timeline.marks.add(newMark)
timeline.saveTimeline(user.timelinePath)
resp(Http201, "ok", TXT)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/amend/@id":
checkAuth(cfg); if not authed: return true
try:
var timeline = loadTimeline(user.timelinePath)
let idx = timeline.marks.findById(@"id")
if idx < 0: resp(Http404, "no mark for id: " & @"id", TXT)
timeline.marks[idx] = timeline.marks[idx].patchMark(parseJson(request.body))
timeline.saveTimeline(user.timelinePath)
resp(Http202, $(%timeline.marks[idx]), JSON)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/users":
checkAuth(cfg); if not authed: return true
if not user.isAdmin: resp(Http403, "insufficient permission", TXT)
var newUser: PtkUser
try: newUser = apiParseUser(parseJson(request.body))
except: resp(Http400, getCurrentExceptionMsg(), TXT)
if cfg.users.anyIt(it.username == newUser.username):
resp(Http409, "user already exists", TXT)
newUser.timelinePath = cfg.dataDir / newUser.username & ".timeline.json"
try:
discard parseAndRun(newUser, "init", newStringTable())
# TODO: save updated config!
# cfg.users.add(newUser)
resp(Http200, "ok", TXT)
except: resp(Http500, "could not init new user timeline", TXT)
waitFor(stopFuture)

116
private/models.nim Normal file
View File

@ -0,0 +1,116 @@
import algorithm, json, sequtils, strutils, times, timeutils, uuids
import ./util
type
Mark* = tuple[id: UUID, time: DateTime, summary: string, notes: string, tags: seq[string]]
## Representation of a single mark on the timeline.
Timeline* = tuple[name: string, marks: seq[Mark]]
## Representation of a timeline: a name and sequence of Marks.
const STOP_MSG* = "STOP"
let NO_MARK*: Mark = (
id: parseUUID("00000000-0000-0000-0000-000000000000"),
time: fromUnix(0).local,
summary: "", notes: "", tags: @[])
const ISO_TIME_FORMAT* = "yyyy-MM-dd'T'HH:mm:ss"
## The canonical time format used by PTK.
const TIME_FORMATS* = @[
"yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd HH:mm",
"MM-dd'T'HH:mm:ss", "MM-dd HH:mm:ss",
"MM-dd'T'HH:mm", "MM-dd HH:mm",
"HH:mm:ss", "H:mm:ss", "H:mm", "HH:mm" ]
## Other time formats that PTK will accept as input.
proc getOrFail*(n: JsonNode, key: string, objName: string = ""): JsonNode =
## convenience method to get a key from a JObject or raise an exception
if not n.hasKey(key): raiseEx objName & " missing key '" & key & "'"
return n[key]
proc getIfExists*(n: JsonNode, key: string): JsonNode =
## convenience method to get a key from a JObject or return null
result = if n.hasKey(key): n[key]
else: newJNull()
proc parseTime*(timeStr: string): DateTime =
## Helper to parse time strings trying multiple known formats.
for fmt in TIME_FORMATS:
try: return parse(timeStr, fmt)
except: discard nil
raise newException(Exception, "unable to interpret as a date: " & timeStr)
proc parseMark*(json: JsonNode): Mark =
return (
id: parseUUID(json["id"].getStr()),
time: parse(json["time"].getStr(), ISO_TIME_FORMAT),
summary: json["summary"].getStr(),
notes: json["notes"].getStr(),
tags: json["tags"].getElems(@[]).map(proc (t: JsonNode): string = t.getStr()))
template `%`*(mark: Mark): JsonNode =
%* {
"id": $(mark.id),
"time": mark.time.format(ISO_TIME_FORMAT),
"summary": mark.summary,
"notes": mark.notes,
"tags": mark.tags
}
template `%`*(timeline: Timeline): JsonNode =
%* { "name": timeline.name, "marks": timeline.marks }
proc loadTimeline*(filename: string): Timeline =
## Load a timeline from a file. Expects a path to a file (can be relative or
## absolute) and returns a Timeline. The marks in the timeline are guaranteed
## to be ordered by time.
var timelineJson: JsonNode
try: timelineJson = parseFile(filename)
except:
raise newException(ValueError,
"unable to parse the timeline file as JSON: " & filename)
var timeline: Timeline = (name: timelineJson["name"].getStr(), marks: @[])
for markJson in timelineJson["marks"]: timeline.marks.add(parseMark(markJson))
timeline.marks = timeline.marks.sorted(
proc(a, b: Mark): int = cmp(a.time, b.time))
return timeline
proc saveTimeline*(timeline: Timeline, location: string): void =
## Write the timeline to disk at the file location given.
var timelineFile: File
try:
timelineFile = open(location, fmWrite)
timelineFile.writeLine(pretty(%timeline))
except: raise newException(IOError, "unable to save changes to " & location)
finally: close(timelineFile)
proc findById*(marks: seq[Mark], id: string): int =
var idx = 0
for mark in marks:
if startsWith($mark.id, id): return idx
inc(idx)
return -1
proc getLastIndex*(marks: seq[Mark]): int =
## Find and return the index of the last Mark that was not a STOP mark.
## Returns -1 if there is no such last mark.
var idx = marks.len - 1
while idx >= 0 and marks[idx].summary == STOP_MSG: idx -= 1
if idx < 0: result = -1
else: result = idx

View File

@ -6,3 +6,6 @@ proc flatten*[T](a: seq[seq[T]]): seq[T] =
result = @[] result = @[]
for subseq in a: for subseq in a:
result.add(subseq) result.add(subseq)
proc raiseEx*(reason: string): void = raise newException(Exception, reason)

1
private/version.nim Normal file
View File

@ -0,0 +1 @@
const PTK_VERSION* = "1.0.0"

160
ptk.nim
View File

@ -6,32 +6,10 @@
import algorithm, docopt, json, langutils, logging, os, nre, sequtils, import algorithm, docopt, json, langutils, logging, os, nre, sequtils,
sets, strutils, tempfile, terminal, times, timeutils, uuids sets, strutils, tempfile, terminal, times, timeutils, uuids
import private/ptkutil import private/util
import private/api
type import private/models
Mark* = tuple[id: UUID, time: DateTime, summary: string, notes: string, tags: seq[string]] import private/version
## Representation of a single mark on the timeline.
Timeline* = tuple[name: string, marks: seq[Mark]]
## Representation of a timeline: a name and sequence of Marks.
const STOP_MSG = "STOP"
let NO_MARK: Mark = (
id: parseUUID("00000000-0000-0000-0000-000000000000"),
time: fromUnix(0).local,
summary: "", notes: "", tags: @[])
const ISO_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"
## The canonical time format used by PTK.
const TIME_FORMATS = @[
"yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd HH:mm",
"MM-dd'T'HH:mm:ss", "MM-dd HH:mm:ss",
"MM-dd'T'HH:mm", "MM-dd HH:mm",
"HH:mm:ss", "H:mm:ss", "H:mm", "HH:mm" ]
## Other time formats that PTK will accept as input.
#proc `$`*(mark: Mark): string = #proc `$`*(mark: Mark): string =
#return (($mark.uuid)[ #return (($mark.uuid)[
@ -40,70 +18,6 @@ proc exitErr(msg: string): void =
fatal "ptk: " & msg fatal "ptk: " & msg
quit(QuitFailure) quit(QuitFailure)
proc parseTime(timeStr: string): DateTime =
## Helper to parse time strings trying multiple known formats.
for fmt in TIME_FORMATS:
try: return parse(timeStr, fmt)
except: discard nil
raise newException(Exception, "unable to interpret as a date: " & timeStr)
template `%`(mark: Mark): JsonNode =
%* {
"id": $(mark.id),
"time": mark.time.format(ISO_TIME_FORMAT),
"summary": mark.summary,
"notes": mark.notes,
"tags": mark.tags
}
template `%`(timeline: Timeline): JsonNode =
%* { "name": timeline.name, "marks": timeline.marks }
proc loadTimeline(filename: string): Timeline =
## Load a timeline from a file. Expects a path to a file (can be relative or
## absolute) and returns a Timeline. The marks in the timeline are guaranteed
## to be ordered by time.
var timelineJson: JsonNode
try: timelineJson = parseFile(filename)
except:
raise newException(ValueError,
"unable to parse the timeline file as JSON: " & filename)
var timeline: Timeline = (name: timelineJson["name"].getStr(), marks: @[])
for markJson in timelineJson["marks"]:
# TODO: an incorrect time format that was used in version 0.6 and prior.
# Version 0.7 between 1.0 support this format on read only and will write
# out the correct format (so they can be used to convert older timelines).
var time: DateTime
try: time = parse(markJson["time"].getStr(), ISO_TIME_FORMAT)
except: time = parse(markJson["time"].getStr(), "yyyy:MM:dd'T'HH:mm:ss")
timeline.marks.add((
id: parseUUID(markJson["id"].getStr()),
time: time, #parse(markJson["time"].getStr(), ISO_TIME_FORMAT),
summary: markJson["summary"].getStr(),
notes: markJson["notes"].getStr(),
tags: markJson["tags"].getElems(@[]).map(proc (t: JsonNode): string = t.getStr())))
timeline.marks = timeline.marks.sorted(
proc(a, b: Mark): int = cmp(a.time, b.time))
return timeline
proc saveTimeline(timeline: Timeline, location: string): void =
## Write the timeline to disk at the file location given.
var timelineFile: File
try:
timelineFile = open(location, fmWrite)
timelineFile.writeLine(pretty(%timeline))
except: raise newException(IOError, "unable to save changes to " & location)
finally: close(timelineFile)
proc flexFormat(i: TimeInterval): string = proc flexFormat(i: TimeInterval): string =
## Pretty-format a time interval. ## Pretty-format a time interval.
@ -188,44 +102,6 @@ proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): vo
writeLine(stdout, spaces(notesPrefixLen) & line) writeLine(stdout, spaces(notesPrefixLen) & line)
writeLine(stdout, "") writeLine(stdout, "")
proc formatMark(mark: Mark, nextMark = NO_MARK, timeFormat = ISO_TIME_FORMAT, includeNotes = false): string =
## Pretty-format a Mark, optionally taking the next Mark in the timeline (to
## compute duration) and a time format string, and conditionally including
## the Mark's notes in the output.
let nextTime =
if nextMark == NO_MARK: getTime().local
else: nextMark.time
let duration = (nextTime - mark.time).flexFormat
# TODO: pick up here calculating the time between marks
let prefix = ($mark.id)[0..<8] & " " & mark.time.format(timeFormat) & " (" & duration & ") -- "
result = prefix & mark.summary
if includeNotes and len(mark.notes.strip()) > 0:
let wrappedNotes = wordWrap(s = mark.notes, maxLineWidth = 80 - prefix.len)
for line in splitLines(wrappedNotes):
result &= "\x0D\x0A" & spaces(prefix.len) & line
result &= "\x0D\x0A"
proc findById(marks: seq[Mark], id: string): int =
var idx = 0
for mark in marks:
if startsWith($mark.id, id): return idx
inc(idx)
return -1
proc getLastIndex(marks: seq[Mark]): int =
## Find and return the index of the last Mark that was not a STOP mark.
## Returns -1 if there is no such last mark.
var idx = marks.len - 1
while idx >= 0 and marks[idx].summary == STOP_MSG: idx -= 1
if idx < 0: result = -1
else: result = idx
proc doInit(timelineLocation: string): void = proc doInit(timelineLocation: string): void =
## Interactively initialize a new timeline at the given file path. ## Interactively initialize a new timeline at the given file path.
@ -357,7 +233,7 @@ proc filterMarkIndices(timeline: Timeline, args: Table[string, Value]): seq[int]
selected = selected.filterMarks(not tags.allIt(marks[mIdx].tags.contains(it))) selected = selected.filterMarks(not tags.allIt(marks[mIdx].tags.contains(it)))
if args["--matching"]: if args["--matching"]:
let pattern = re(args["--matching"] ?: "") let pattern = re("(?i)" & $(args["--matching"] ?: ""))
selected = selected.filterMarks(marks[mIdx].summary.find(pattern).isSome) selected = selected.filterMarks(marks[mIdx].summary.find(pattern).isSome)
return sequtils.toSeq(selected.items).sorted(system.cmp) return sequtils.toSeq(selected.items).sorted(system.cmp)
@ -380,6 +256,7 @@ Usage:
ptk current ptk current
ptk sum-time --ids <ids>... ptk sum-time --ids <ids>...
ptk sum-time [options] [<firstId>] [<lastId>] ptk sum-time [options] [<firstId>] [<lastId>]
ptk serve-api <svcCfg> [--port <port>]
ptk (-V | --version) ptk (-V | --version)
ptk (-h | --help) ptk (-h | --help)
@ -407,13 +284,12 @@ Options:
-v --verbose Include notes in timeline entry output. -v --verbose Include notes in timeline entry output.
""" """
# TODO: add ptk delete [options]
logging.addHandler(newConsoleLogger()) logging.addHandler(newConsoleLogger())
let now = getTime().local let now = getTime().local
# Parse arguments # Parse arguments
let args = docopt(doc, version = "ptk 0.12.0") let args = docopt(doc, version = PTK_VERSION)
if args["--echo-args"]: echo $args if args["--echo-args"]: echo $args
@ -423,7 +299,7 @@ Options:
# Find and parse the .ptkrc file # Find and parse the .ptkrc file
let ptkrcLocations = @[ let ptkrcLocations = @[
if args["--config"]: $args["<cfgFile>"] else:"", if args["--config"]: $args["--config"] else:"",
".ptkrc", $getEnv("PTKRC"), $getEnv("HOME") & "/.ptkrc"] ".ptkrc", $getEnv("PTKRC"), $getEnv("HOME") & "/.ptkrc"]
var ptkrcFilename: string = var ptkrcFilename: string =
@ -447,7 +323,7 @@ Options:
# Find the time log file # Find the time log file
let timelineLocations = @[ let timelineLocations = @[
if args["--file"]: $args["<file>"] else: "", if args["--file"]: $args["--file"] else: "",
$getEnv("PTK_FILE"), $getEnv("PTK_FILE"),
cfg["timelineLogFile"].getStr(""), cfg["timelineLogFile"].getStr(""),
"ptk.log.json"] "ptk.log.json"]
@ -493,6 +369,10 @@ Options:
if args["stop"]: if args["stop"]:
if timeline.marks.last.summary == STOP_MSG:
echo "ptk: no current task, nothing to stop"
quit(0)
let newMark: Mark = ( let newMark: Mark = (
id: genUUID(), id: genUUID(),
time: if args["--time"]: parseTime($args["--time"]) else: now, time: if args["--time"]: parseTime($args["--time"]) else: now,
@ -505,14 +385,14 @@ Options:
timeline.writeMarks( timeline.writeMarks(
indices = @[timeline.marks.len - 2], indices = @[timeline.marks.len - 2],
includeNotes = args["--verbose"]) includeNotes = args["--verbose"])
echo "stopped timer" echo "ptk: stopped timer"
saveTimeline(timeline, timelineLocation) saveTimeline(timeline, timelineLocation)
if args["continue"]: if args["continue"]:
if timeline.marks.last.summary != STOP_MSG: if timeline.marks.last.summary != STOP_MSG:
echo "There is already something in progress:" echo "ptk: there is already something in progress:"
timeline.writeMarks( timeline.writeMarks(
indices = @[timeline.marks.len - 1], indices = @[timeline.marks.len - 1],
includeNotes = args["--verbose"]) includeNotes = args["--verbose"])
@ -681,6 +561,16 @@ Options:
let total = intervals.foldl(a + b) let total = intervals.foldl(a + b)
echo flexFormat(total) echo flexFormat(total)
if args["serve-api"]:
if not fileExists($args["<svcCfg>"]):
exitErr "cannot find service config file: '" & $args["<svcCfg>"]
var apiCfg = loadApiConfig(parseFile($args["<svcCfg>"]))
if args["--port"]: apiCfg.port = parseInt($args["--port"])
start_api(apiCfg)
except: except:
fatal "ptk: " & getCurrentExceptionMsg() fatal "ptk: " & getCurrentExceptionMsg()
quit(QuitFailure) quit(QuitFailure)

View File

@ -1,6 +1,7 @@
# Package # Package
include "private/version.nim"
version = "0.12.0" version = PTK_VERSION
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Personal Time Keeper" description = "Personal Time Keeper"
license = "MIT" license = "MIT"
@ -8,5 +9,5 @@ bin = @["ptk"]
# Dependencies # Dependencies
requires @["nim >= 0.18.0", "docopt >= 0.6.4", "uuids", "langutils", "tempfile", "timeutils >= 0.2.2", "isaac >= 0.1.2"] requires @["nim >= 0.18.0", "docopt >= 0.6.4", "uuids", "langutils", "tempfile", "timeutils >= 0.2.2", "isaac >= 0.1.2", "bcrypt", "cliutils >= 0.5.0", "jester 0.2.0"]