12 Commits

Author SHA1 Message Date
af6aa5d520 Update for Nim 1.0.0 2020-02-14 12:38:28 -06:00
0d4827453a Refactor to add API layer. 2018-10-03 04:01:29 -05:00
03da2e9cd9 WIP Refactor to support API. 2018-10-02 09:19:59 -05:00
e62a4e31de Fix --file flag, cut dead code. 2018-10-01 19:03:53 -05:00
5140afa671 All informational messages start with 'ptk:'. 2018-05-16 12:13:00 -05:00
56be47f7e1 Stop no longer appends extra stop messages if we're already stopped. 2018-05-16 11:50:08 -05:00
75f74c3a0a Make -m case insensitive. 2018-04-30 11:05:46 -05:00
e5c6e6187c Add --yesterday and --or filter criteria.
* `--yesterday` is like `--today`, but for yesterday 😄.
* `--or` changes the behavior of filter criteria to return a union of
  events matching an of the criteria than an intersection of events
  matching all the criteria. Example:

      ptk list --today --yesterday

  Will never return results (because the criteria are mutually
  exclusive).

      ptk list --today --or --yesterday

  Will return the marks for both today and yesterday.
2018-04-27 13:22:06 -05:00
81d326c5c8 Filter out empty whitespace when considering new tags. 2018-04-16 04:29:19 -05:00
72c332fa45 Add start as an alias for add. 2018-04-09 09:53:00 -05:00
4a878026d8 Bump library version to compile under Nim 0.18 2018-04-02 14:44:26 -05:00
ee733957c6 Add current command. Reformat output of notes. 2017-09-19 10:37:28 -05:00
6 changed files with 501 additions and 182 deletions

253
private/api.nim Normal file
View File

@ -0,0 +1,253 @@
## Personal Time Keeping API Interface
## ===================================
import asyncdispatch, base64, bcrypt, cliutils, docopt, jester, json, logging,
ospaths, sequtils, strutils, os, tables, 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 halt(code: HttpCode,
headers: RawHeaders,
content: string): typed =
## Immediately replies with the specified request. This means any further
## code will not be executed after calling this template in the current
## route.
bind TCActionSend, newHttpHeaders
result[0] = CallbackAction.TCActionSend
result[1] = code
result[2] = some(headers)
result[3] = content
result.matched = true
break allRoutes
template checkAuth(cfg: PtkApiCfg) =
## Check this request for authentication and authorization information.
## If the request is not authorized, this template immediately returns a
## 401 Unauthotized response
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()
halt(
Http401,
@{"Content-Type": TXT, "WWW_Authenticate": "Basic" },
getCurrentExceptionMsg())
proc parseAndRun(user: PtkUser, cmd: string, params: Table[string, string]): 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)
try: resp(parseAndRun(user, "list", request.params), TXT)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/continue":
checkAuth(cfg)
try: resp(parseAndRun(user, "continue", request.params), TXT)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/sum-time":
checkAuth(cfg)
try: resp(parseAndRun(user, "sum-time", request.params), TXT)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/mark":
checkAuth(cfg)
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)
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)
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)
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 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", initTable[string,string]())
# 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.1"

293
ptk.nim
View File

@ -4,34 +4,14 @@
## Simple time keeping CLI ## Simple time keeping CLI
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, uuids
import private/ptkutil import timeutils except `-`;
type import private/util
Mark* = tuple[id: UUID, time: TimeInfo, summary: string, notes: string, tags: seq[string]] import private/api
## Representation of a single mark on the timeline. import private/models
import private/version
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: fromSeconds(0).getLocalTime,
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,97 +20,37 @@ proc exitErr(msg: string): void =
fatal "ptk: " & msg fatal "ptk: " & msg
quit(QuitFailure) quit(QuitFailure)
proc parseTime(timeStr: string): TimeInfo = proc flexFormat(i: Duration): string =
## 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: TimeInfo
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 =
## Pretty-format a time interval. ## Pretty-format a time interval.
let fmt = let fmt =
if i > 1.days: "d'd' H'h' m'm'" if i > initDuration(days = 1): "d'd' H'h' m'm'"
elif i >= 1.hours: "H'h' m'm'" elif i >= initDuration(hours = 1): "H'h' m'm'"
elif i >= 1.minutes: "m'm' s's'" elif i >= initDuration(minutes = 1): "m'm' s's'"
else: "s's'" else: "s's'"
return i.format(fmt) return i.format(fmt)
type WriteData = tuple[idx: int, mark: Mark, prefixLen: int, interval: TimeInterval] type WriteData = tuple[idx: int, mark: Mark, prefixLen: int, interval: Duration]
proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): void = proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): void =
## Write a nicely-formatted list of Marks to stdout. ## Write a nicely-formatted list of Marks to stdout.
let marks = timeline.marks let marks = timeline.marks
let now = getLocalTime(getTime()) let now = getTime().local
if indices.len == 0:
writeLine(stdout, "No marks match the given criteria.")
return
var idxs = indices.sorted( var idxs = indices.sorted(
proc(a, b: int): int = cmp(marks[a].time, marks[b].time)) proc(a, b: int): int = cmp(marks[a].time, marks[b].time))
let largestInterval = now - marks[idxs.first].time let largestInterval = now - marks[idxs.first].time
let timeFormat = let timeFormat =
if largestInterval > 1.years: "yyyy-MM-dd HH:mm" if largestInterval > initDuration(days = 365): "yyyy-MM-dd HH:mm"
elif largestInterval > 7.days: "MMM dd HH:mm" elif largestInterval > initDuration(days = 7): "MMM dd HH:mm"
elif largestInterval > 1.days: "ddd HH:mm" elif largestInterval > initDuration(days = 1): "ddd HH:mm"
else: "HH:mm" else: "HH:mm"
var toWrite: seq[WriteData] = @[] var toWrite: seq[WriteData] = @[]
@ -139,7 +59,7 @@ proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): vo
for i in idxs: for i in idxs:
let let
interval: TimeInterval = interval: Duration =
if (i == marks.len - 1): now - marks[i].time if (i == marks.len - 1): now - marks[i].time
else: marks[i + 1].time - marks[i].time else: marks[i + 1].time - marks[i].time
prefix = prefix =
@ -154,6 +74,9 @@ proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): vo
if prefix.len > longestPrefix: longestPrefix = prefix.len if prefix.len > longestPrefix: longestPrefix = prefix.len
let colWidth = 80
let notesPrefixLen = 4
for w in toWrite: for w in toWrite:
if w.mark.summary == STOP_MSG: continue if w.mark.summary == STOP_MSG: continue
@ -174,47 +97,12 @@ proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): vo
writeLine(stdout, "") writeLine(stdout, "")
if includeNotes and len(w.mark.notes.strip) > 0: if includeNotes and len(w.mark.notes.strip) > 0:
writeLine(stdout, spaces(longestPrefix) & w.mark.notes)
writeLine(stdout, "") writeLine(stdout, "")
let wrappedNotes = wordWrap(s = w.mark.notes,
proc formatMark(mark: Mark, nextMark = NO_MARK, timeFormat = ISO_TIME_FORMAT, includeNotes = false): string = maxLineWidth = colWidth)
## Pretty-format a Mark, optionally taking the next Mark in the timeline (to for line in splitLines(wrappedNotes):
## compute duration) and a time format string, and conditionally including writeLine(stdout, spaces(notesPrefixLen) & line)
## the Mark's notes in the output. writeLine(stdout, "")
let nextTime =
if nextMark == NO_MARK: getLocalTime(getTime())
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 & ") -- "
let prefixLen = len(($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 - prefixLen)
for line in splitLines(wrappedNotes):
result &= "\x0D\x0A" & spaces(prefixLen) & 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.
@ -277,69 +165,88 @@ proc filterMarkIndices(timeline: Timeline, args: Table[string, Value]): seq[int]
## arguments. ## arguments.
let marks = timeline.marks let marks = timeline.marks
result = sequtils.toSeq(0..<marks.len).filterIt(marks[it].summary != STOP_MSG) let now = getTime().local
let allIndices = sequtils.toSeq(0..<marks.len).filterIt(marks[it].summary != STOP_MSG).toSet
let union = args["--or"]
var selected =
if union: initSet[int]()
else: allIndices
template filterMarks(curSet: HashSet[int], pred: untyped): untyped =
var res: HashSet[int] = initSet[int]()
if union:
for mIdx {.inject.} in allIndices:
if pred: res.incl(mIdx)
res = res + curSet
else:
for mIdx {.inject.} in curSet:
if pred: res.incl(mIdx)
res
if args["<firstId>"]: if args["<firstId>"]:
let idx = marks.findById($args["<firstId>"]) let idx = marks.findById($args["<firstId>"])
if idx > 0: result = result.filterIt(it >= idx) if idx > 0: selected = selected.filterMarks(mIdx >= idx)
if args["<lastId>"]: if args["<lastId>"]:
let idx = marks.findById($args["<lastId>"]) let idx = marks.findById($args["<lastId>"])
if (idx > 0): result = result.filterIt(it <= idx) if (idx > 0): selected = selected.filterMarks(mIdx <= idx)
if args["--after"]: if args["--after"]:
var startTime: TimeInfo var startTime: DateTime
try: startTime = parseTime($args["--after"]) try: startTime = parseTime($args["--after"])
except: raise newException(ValueError, except: raise newException(ValueError,
"invalid value for --after: " & getCurrentExceptionMsg()) "invalid value for --after: " & getCurrentExceptionMsg())
result = result.filterIt(marks[it].time > startTime) selected = selected.filterMarks(marks[mIdx].time > startTime)
if args["--before"]: if args["--before"]:
var endTime: TimeInfo var endTime: DateTime
try: endTime = parseTime($args["--before"]) try: endTime = parseTime($args["--before"])
except: raise newException(ValueError, except: raise newException(ValueError,
"invalid value for --before: " & getCurrentExceptionMsg()) "invalid value for --before: " & getCurrentExceptionMsg())
result = result.filterIt(marks[it].time < endTime) selected = selected.filterMarks(marks[mIdx].time < endTime)
if args["--today"]: if args["--today"]:
let now = getLocalTime(getTime())
let b = now.startOfDay let b = now.startOfDay
let e = b + 1.days let e = b + 1.days
result = result.filterIt(marks[it].time >= b and marks[it].time < e) selected = selected.filterMarks(marks[mIdx].time >= b and marks[mIdx].time < e)
if args["--yesterday"]:
let e = now.startOfDay
let b = e - 1.days
selected = selected.filterMarks(marks[mIdx].time >= b and marks[mIdx].time < e)
if args["--this-week"]: if args["--this-week"]:
let now = getLocalTime(getTime())
let b = now.startOfWeek(dSun) let b = now.startOfWeek(dSun)
let e = b + 7.days let e = b + 7.days
result = result.filterIt(marks[it].time >= b and marks[it].time < e) selected = selected.filterMarks(marks[mIdx].time >= b and marks[mIdx].time < e)
if args["--last-week"]: if args["--last-week"]:
let now = getLocalTime(getTime())
let e = now.startOfWeek(dSun) let e = now.startOfWeek(dSun)
let b = e - 7.days let b = e - 7.days
result = result.filterIt(marks[it].time >= b and marks[it].time < e) selected = selected.filterMarks(marks[mIdx].time >= b and marks[mIdx].time < e)
if args["--tags"]: if args["--tags"]:
let tags = (args["--tags"] ?: "").split({',', ';'}) let tags = (args["--tags"] ?: "").split({',', ';'})
result = result.filter(proc (i: int): bool = selected = selected.filterMarks(tags.allIt(marks[mIdx].tags.contains(it)))
tags.allIt(marks[i].tags.contains(it)))
if args["--remove-tags"]: if args["--remove-tags"]:
let tags = (args["--remove-tags"] ?: "").split({',', ';'}) let tags = (args["--remove-tags"] ?: "").split({',', ';'})
result = result.filter(proc (i: int): bool = selected = selected.filterMarks(not tags.allIt(marks[mIdx].tags.contains(it)))
not tags.allIt(marks[i].tags.contains(it)))
if args["--matching"]: if args["--matching"]:
let pattern = re(args["--matching"] ?: "") let pattern = re("(?i)" & $(args["--matching"] ?: ""))
result = result.filterIt(marks[it].summary.find(pattern).isSome) selected = selected.filterMarks(marks[mIdx].summary.find(pattern).isSome)
return sequtils.toSeq(selected.items).sorted(system.cmp)
when isMainModule: when isMainModule:
try: try:
let doc = """ let doc = """
Usage: Usage:
ptk init [options] ptk init [options]
ptk add [options] ptk (add | start) [options]
ptk add [options] <summary> ptk (add | start) [options] <summary>
ptk resume [options] [<id>] ptk resume [options] [<id>]
ptk amend [options] [<id>] [<summary>] ptk amend [options] [<id>] [<summary>]
ptk merge <timeline> [<timeline>...] ptk merge <timeline> [<timeline>...]
@ -348,8 +255,10 @@ Usage:
ptk delete <id> ptk delete <id>
ptk (list | ls) [options] ptk (list | ls) [options]
ptk (list | ls) tags ptk (list | ls) tags
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)
@ -369,18 +278,20 @@ Options:
-n --notes <notes> For add and amend, set the notes for a time mark. -n --notes <notes> For add and amend, set the notes for a time mark.
-t --time <time> For add and amend, use this time instead of the current time. -t --time <time> For add and amend, use this time instead of the current time.
-T --today Restrict the selection to marks during today. -T --today Restrict the selection to marks during today.
-Y --yesterday Restrict the selection to marks during yesterday.
-w --this-week Restrict the selection to marks during this week. -w --this-week Restrict the selection to marks during this week.
-W --last-week Restrict the selection to marks during the last week. -W --last-week Restrict the selection to marks during the last week.
-O --or Create a union from the time conditionals, not an intersection
(e.g. --today --or --yesterday)
-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 = getLocalTime(getTime()) let now = getTime().local
# Parse arguments # Parse arguments
let args = docopt(doc, version = "ptk 0.10.0") let args = docopt(doc, version = PTK_VERSION)
if args["--echo-args"]: echo $args if args["--echo-args"]: echo $args
@ -390,7 +301,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 =
@ -414,7 +325,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"]
@ -460,26 +371,30 @@ 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,
summary: STOP_MSG, summary: STOP_MSG,
notes: args["--notes"] ?: "", notes: args["--notes"] ?: "",
tags: (args["--tags"] ?: "").split({',', ';'})) tags: (args["--tags"] ?: "").split({',', ';'}).filterIt(not it.isNilOrWhitespace))
timeline.marks.add(newMark) timeline.marks.add(newMark)
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"])
@ -500,14 +415,14 @@ Options:
saveTimeline(timeline, timelineLocation) saveTimeline(timeline, timelineLocation)
if args["add"]: if args["add"] or args["start"]:
var newMark: Mark = ( var newMark: Mark = (
id: genUUID(), id: genUUID(),
time: if args["--time"]: parseTime($args["--time"]) else: now, time: if args["--time"]: parseTime($args["--time"]) else: now,
summary: args["<summary>"] ?: "", summary: args["<summary>"] ?: "",
notes: args["--notes"] ?: "", notes: args["--notes"] ?: "",
tags: (args["--tags"] ?: "").split({',', ';'})) tags: (args["--tags"] ?: "").split({',', ';'}).filterIt(not it.isNilOrWhitespace))
if args["--edit"]: edit(newMark) if args["--edit"]: edit(newMark)
@ -531,7 +446,7 @@ Options:
markToResumeIdx = timeline.marks.getLastIndex() markToResumeIdx = timeline.marks.getLastIndex()
if markToResumeIdx < 0: exitErr "No mark to resume." if markToResumeIdx < 0: exitErr "No mark to resume."
var markToResume = timeline.marks[markToResumeIdx] var markToResume = timeline.marks[markToResumeIdx]
var newMark: Mark = ( var newMark: Mark = (
id: genUUID(), id: genUUID(),
time: if args["--time"]: parseTime($args["--time"]) else: now, time: if args["--time"]: parseTime($args["--time"]) else: now,
@ -545,7 +460,7 @@ Options:
timeline.writeMarks( timeline.writeMarks(
indices = sequtils.toSeq(markToResumeIdx..<timeline.marks.len), indices = sequtils.toSeq(markToResumeIdx..<timeline.marks.len),
includeNotes = args["--verbose"]) includeNotes = args["--verbose"])
saveTimeline(timeline, timelineLocation) saveTimeline(timeline, timelineLocation)
if args["amend"]: if args["amend"]:
@ -561,7 +476,7 @@ Options:
if markIdx < 0: exitErr "No mark to amend." if markIdx < 0: exitErr "No mark to amend."
var mark = timeline.marks[markIdx] var mark = timeline.marks[markIdx]
if args["<summary>"]: mark.summary = $args["<summary>"] if args["<summary>"]: mark.summary = $args["<summary>"]
if args["--notes"]: mark.notes = $args["<notes>"] if args["--notes"]: mark.notes = $args["<notes>"]
if args["--tags"]: if args["--tags"]:
@ -606,11 +521,21 @@ Options:
timeline.writeMarks( timeline.writeMarks(
indices = selectedIndices, indices = selectedIndices,
includeNotes = args["--version"]) includeNotes = args["--verbose"])
if args["current"]:
let idx = timeline.marks.len - 1
if timeline.marks[idx].summary == STOP_MSG:
echo "ptk: no current task"
else:
timeline.writeMarks(
indices = @[idx],
includeNotes = true)
if args["sum-time"]: if args["sum-time"]:
var intervals: seq[TimeInterval] = @[] var intervals: seq[Duration] = @[]
if args["--ids"]: if args["--ids"]:
for id in args["<ids>"]: for id in args["<ids>"]:
@ -637,7 +562,17 @@ Options:
else: else:
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.10.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,15 @@ bin = @["ptk"]
# Dependencies # Dependencies
requires @["nim >= 0.15.0", "docopt >= 0.6.4", "uuids", "langutils", "tempfile", "timeutils >= 0.2.0", "isaac >= 0.1.2"] requires @[
"nim >= 1.0.0",
"docopt >= 0.6.8",
"uuids",
"tempfile",
"isaac >= 0.1.3",
"bcrypt",
"jester 0.4.1",
"https://git.jdb-labs.com/jdb/nim-lang-utils.git",
"https://git.jdb-labs.com/jdb/nim-cli-utils.git",
"https://git.jdb-labs.com/jdb/nim-time-utils.git >= 0.5.2"
]