22 Commits
0.2.1 ... 1.0.0

Author SHA1 Message Date
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
d75f5607c2 Fix edge case when no marks exist yet. 2017-05-16 12:32:59 -05:00
15708cebdf Add explicit dependency on isaac >= 0.1.2
Something depends on isaac 0.1.0, but that version doesn't compile on Nim 0.16
and above. Until the transitive dependancy is updated, ask for at least 0.1.2.
2017-02-21 11:18:12 -06:00
78a35b6478 Added list tags command. 2017-02-09 10:57:51 -06:00
099848edca Add resume command, update documentation. 2016-12-13 12:29:03 -06:00
d3fc1cdf9c Fix incorrect ISO time format.
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).
2016-11-01 09:57:36 -05:00
237f5026f2 Added merge command. 2016-10-23 17:31:27 -05:00
3cf76ef382 Add --this-week, --last-week options 2016-10-21 15:42:48 -05:00
e0618f6520 Add some alternate time formats. 2016-10-13 06:31:51 -05:00
915c5b1ea1 Added --today flag. 2016-10-12 16:30:32 -05:00
9d0c77c8af Add support for using tags and pattern matching to select marks to list or sum. 2016-10-11 16:13:26 -05:00
033862f793 Added README and TODO. 2016-10-11 13:29:31 -05:00
9 changed files with 677 additions and 212 deletions

14
README.md Normal file
View File

@ -0,0 +1,14 @@
# Personal Time Keeper
`ptk` is a small utility to log time entries. It uses a simple conceptual model
and a simple JSON data format.
A ptk timeline is made up of a series of ptk entries, or marks. Each mark has a
summary, a timestamp, and a universally unique id (generated by `ptk`).
Additionally a mark may be tagged with an arbitrary number of tags and may have
detailed notes attached to it (anything representable in plain text).
The duration of a task is calculated by taking the difference between that
task's timestamp and the one following it chronologically. The `STOP` value as
a summary serves as a sentinal to indicate that an entry has been completed
and no new entry is being tracked.

1
TODO.md Normal file
View File

@ -0,0 +1 @@
* Sync with web timestamper.

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

11
private/util.nim Normal file
View File

@ -0,0 +1,11 @@
template first*(a: openarray): auto = a[0]
template last*(a: openarray): auto = a[len(a)-1]
proc flatten*[T](a: seq[seq[T]]): seq[T] =
result = @[]
for subseq in a:
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"

499
ptk.nim
View File

@ -3,26 +3,13 @@
## ##
## Simple time keeping CLI ## Simple time keeping CLI
import algorithm, docopt, json, langutils, logging, os, sequtils, strutils, import algorithm, docopt, json, langutils, logging, os, nre, sequtils,
tempfile, terminal, times, timeutils, uuids sets, strutils, tempfile, terminal, times, timeutils, uuids
import ptkutil
type import private/util
Mark* = tuple[id: UUID, time: TimeInfo, summary: string, notes: string, tags: seq[string]] import private/api
Timeline* = tuple[name: string, marks: seq[Mark]] import private/models
import private/version
const STOP_MSG = "STOP"
let NO_MARK: Mark = (
id: parseUUID("00000000-0000-0000-0000-000000000000"),
time: getLocalTime(getTime()),
summary: "", notes: "", tags: @[])
const ISO_TIME_FORMAT = "yyyy:MM:dd'T'HH:mm:ss"
const TIME_FORMATS = @[
"H:mm", "HH:mm", "H:mm:ss", "HH:mm:ss",
"yyyy:MM:dd'T'HH:mm:ss", "yyyy:MM:dd'T'HH:mm"]
#proc `$`*(mark: Mark): string = #proc `$`*(mark: Mark): string =
#return (($mark.uuid)[ #return (($mark.uuid)[
@ -31,53 +18,9 @@ proc exitErr(msg: string): void =
fatal "ptk: " & msg fatal "ptk: " & msg
quit(QuitFailure) quit(QuitFailure)
proc parseTime(timeStr: string): TimeInfo =
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 =
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((
id: parseUUID(markJson["id"].getStr()),
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())))
return timeline
proc saveTimeline(timeline: Timeline, location: string): void =
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.
let fmt = let fmt =
if i > 1.days: "d'd' H'h' m'm'" if i > 1.days: "d'd' H'h' m'm'"
elif i >= 1.hours: "H'h' m'm'" elif i >= 1.hours: "H'h' m'm'"
@ -86,84 +29,81 @@ proc flexFormat(i: TimeInterval): string =
return i.format(fmt) return i.format(fmt)
proc writeMarks(marks: seq[Mark], includeNotes = false): void = type WriteData = tuple[idx: int, mark: Mark, prefixLen: int, interval: TimeInterval]
let now = getLocalTime(getTime())
proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): void =
## Write a nicely-formatted list of Marks to stdout.
let marks = timeline.marks
let now = getTime().local
if indices.len == 0:
writeLine(stdout, "No marks match the given criteria.")
return
var idxs = indices.sorted(
proc(a, b: int): int = cmp(marks[a].time, marks[b].time))
let largestInterval = now - marks[idxs.first].time
let timeFormat = let timeFormat =
if now - marks.first.time > 1.years: "yyyy-MM-dd HH:mm" if largestInterval > 1.years: "yyyy-MM-dd HH:mm"
elif now - marks.first.time > 7.days: "MMM dd HH:mm" elif largestInterval > 7.days: "MMM dd HH:mm"
elif now - marks.first.time > 1.days: "ddd HH:mm" elif largestInterval > 1.days: "ddd HH:mm"
else: "HH:mm" else: "HH:mm"
var intervals: seq[TimeInterval] = @[] var toWrite: seq[WriteData] = @[]
for i in 0..<marks.len - 1: intervals.add(marks[i+1].time - marks[i].time)
intervals.add(now - marks.last.time)
var prefixLens: seq[int] = @[]
var longestPrefix = 0 var longestPrefix = 0
for i in 0..<marks.len:
let
mark = marks[i]
interval = intervals[i]
prefix = ($mark.id)[0..<8] & " " & mark.time.format(timeFormat) & " (" & interval.flexFormat & ")"
prefixLens.add(prefix.len) for i in idxs:
let
interval: TimeInterval =
if (i == marks.len - 1): now - marks[i].time
else: marks[i + 1].time - marks[i].time
prefix =
($marks[i].id)[0..<8] & " " & marks[i].time.format(timeFormat) &
" (" & interval.flexFormat & ")"
toWrite.add((
idx: i,
mark: marks[i],
prefixLen: prefix.len,
interval: interval))
if prefix.len > longestPrefix: longestPrefix = prefix.len if prefix.len > longestPrefix: longestPrefix = prefix.len
for i in 0..<marks.len: let colWidth = 80
let mark = marks[i] let notesPrefixLen = 4
if mark.summary == STOP_MSG: continue for w in toWrite:
if w.mark.summary == STOP_MSG: continue
let duration = intervals[i].flexFormat
setForegroundColor(stdout, fgBlack, true) setForegroundColor(stdout, fgBlack, true)
write(stdout, ($mark.id)[0..<8]) write(stdout, ($w.mark.id)[0..<8])
setForegroundColor(stdout, fgYellow) setForegroundColor(stdout, fgYellow)
write(stdout, " " & mark.time.format(timeFormat)) write(stdout, " " & w.mark.time.format(timeFormat))
setForegroundColor(stdout, fgCyan) setForegroundColor(stdout, fgCyan)
write(stdout, " (" & duration & ")") write(stdout, " (" & w.interval.flexFormat & ")")
resetAttributes(stdout) resetAttributes(stdout)
write(stdout, spaces(longestPrefix - prefixLens[i]) & " -- " & mark.summary) write(stdout, spaces(longestPrefix - w.prefixLen) & " -- " & w.mark.summary)
if mark.tags.len > 0: if w.mark.tags.len > 0:
setForegroundColor(stdout, fgGreen) setForegroundColor(stdout, fgGreen)
write(stdout, " (" & mark.tags.join(", ") & ")") write(stdout, " (" & w.mark.tags.join(", ") & ")")
resetAttributes(stdout) resetAttributes(stdout)
writeLine(stdout, "") writeLine(stdout, "")
if includeNotes and len(mark.notes.strip) > 0: if includeNotes and len(w.mark.notes.strip) > 0:
writeLine(stdout, spaces(longestPrefix) & mark.notes) writeLine(stdout, "")
let wrappedNotes = wordWrap(s = w.mark.notes,
maxLineWidth = colWidth)
for line in splitLines(wrappedNotes):
writeLine(stdout, spaces(notesPrefixLen) & line)
writeLine(stdout, "") writeLine(stdout, "")
proc formatMark(mark: Mark, nextMark = NO_MARK, timeFormat = ISO_TIME_FORMAT, includeNotes = false): string =
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 doInit(timelineLocation: string): void = proc doInit(timelineLocation: string): void =
## Interactively initialize a new timeline at the given file path.
stdout.write "Time log name [New Timeline]: " stdout.write "Time log name [New Timeline]: "
let name = stdin.readLine() let name = stdin.readLine()
@ -182,6 +122,9 @@ proc doInit(timelineLocation: string): void =
type ExpectedMarkPart = enum Time, Summary, Tags, Notes type ExpectedMarkPart = enum Time, Summary, Tags, Notes
proc edit(mark: var Mark): void = proc edit(mark: var Mark): void =
## Interactively edit a mark using the editor named in the environment
## variable "EDITOR"
var var
tempFile: File tempFile: File
tempFileName: string tempFileName: string
@ -215,20 +158,105 @@ proc edit(mark: var Mark): void =
finally: close(tempFile) finally: close(tempFile)
proc filterMarkIndices(timeline: Timeline, args: Table[string, Value]): seq[int] =
## Filter down a set of marks according to options provided in command line
## arguments.
let marks = timeline.marks
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>"]:
let idx = marks.findById($args["<firstId>"])
if idx > 0: selected = selected.filterMarks(mIdx >= idx)
if args["<lastId>"]:
let idx = marks.findById($args["<lastId>"])
if (idx > 0): selected = selected.filterMarks(mIdx <= idx)
if args["--after"]:
var startTime: DateTime
try: startTime = parseTime($args["--after"])
except: raise newException(ValueError,
"invalid value for --after: " & getCurrentExceptionMsg())
selected = selected.filterMarks(marks[mIdx].time > startTime)
if args["--before"]:
var endTime: DateTime
try: endTime = parseTime($args["--before"])
except: raise newException(ValueError,
"invalid value for --before: " & getCurrentExceptionMsg())
selected = selected.filterMarks(marks[mIdx].time < endTime)
if args["--today"]:
let b = now.startOfDay
let e = b + 1.days
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"]:
let b = now.startOfWeek(dSun)
let e = b + 7.days
selected = selected.filterMarks(marks[mIdx].time >= b and marks[mIdx].time < e)
if args["--last-week"]:
let e = now.startOfWeek(dSun)
let b = e - 7.days
selected = selected.filterMarks(marks[mIdx].time >= b and marks[mIdx].time < e)
if args["--tags"]:
let tags = (args["--tags"] ?: "").split({',', ';'})
selected = selected.filterMarks(tags.allIt(marks[mIdx].tags.contains(it)))
if args["--remove-tags"]:
let tags = (args["--remove-tags"] ?: "").split({',', ';'})
selected = selected.filterMarks(not tags.allIt(marks[mIdx].tags.contains(it)))
if args["--matching"]:
let pattern = re("(?i)" & $(args["--matching"] ?: ""))
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 amend [options] <id> [<summary>] ptk resume [options] [<id>]
ptk amend [options] [<id>] [<summary>]
ptk merge <timeline> [<timeline>...]
ptk stop [options] ptk stop [options]
ptk continue ptk continue
ptk delete <id> ptk delete <id>
ptk (list | ls) [options] ptk (list | ls) [options]
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)
@ -244,17 +272,24 @@ Options:
-g --tags <tags> Add the given tags (comma-separated) to the selected marks. -g --tags <tags> Add the given tags (comma-separated) to the selected marks.
-G --remove-tags <tagx> Remove the given tag from the selected marks. -G --remove-tags <tagx> Remove the given tag from the selected marks.
-h --help Print this usage information. -h --help Print this usage information.
-m --matching <pattern> Restric the selection to marks matching <pattern>.
-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.
-Y --yesterday Restrict the selection to marks during yesterday.
-w --this-week Restrict the selection to marks during this 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 = getTime().local
# Parse arguments # Parse arguments
let args = docopt(doc, version = "ptk 0.2.1") let args = docopt(doc, version = PTK_VERSION)
if args["--echo-args"]: echo $args if args["--echo-args"]: echo $args
@ -264,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 =
@ -288,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"]
@ -300,6 +335,30 @@ Options:
if args["init"]: if args["init"]:
doInit(foldl(timelineLocations, if len(a) > 0: a else: b)) doInit(foldl(timelineLocations, if len(a) > 0: a else: b))
elif args["merge"]:
let filesToMerge = args["<timeline>"]
let timelines = filesToMerge.mapIt(loadTimeline(it))
let names = timelines.mapIt(it.name).toSet
let mergedName = sequtils.toSeq(names.items).foldl(a & " + " & b)
var merged: Timeline = (
name: mergedName,
marks: @[])
for timeline in timelines:
for mark in timeline.marks:
var existingMarkIdx = merged.marks.findById($mark.id)
if existingMarkIdx >= 0:
if merged.marks[existingMarkIdx].summary != mark.summary:
merged.marks[existingMarkIdx].summary &= " | " & mark.summary
if merged.marks[existingMarkIdx].notes != mark.notes:
merged.marks[existingMarkIdx].notes &= "\r\n--------\r\b" & mark.notes
else: merged.marks.add(mark)
writeLine(stdout, pretty(%merged))
else: else:
if not fileExists(timelineLocation): if not fileExists(timelineLocation):
@ -310,71 +369,112 @@ 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: time: if args["--time"]: parseTime($args["--time"]) else: now,
if args["--time"]: parseTime($args["--time"])
else: getLocalTime(getTime()),
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)
writeMarks(
marks = timeline.marks[timeline.marks.len - 2..<timeline.marks.len], timeline.writeMarks(
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:"
writeMarks( timeline.writeMarks(
marks = @[timeline.marks.last], indices = @[timeline.marks.len - 1],
includeNotes = args["--verbose"]) includeNotes = args["--verbose"])
quit(0) quit(0)
let prevMark = timeline.marks[timeline.marks.len - 2] let prevMark = timeline.marks[timeline.marks.len - 2]
var newMark: Mark = ( var newMark: Mark = (
id: genUUID(), id: genUUID(),
time: time: if args["--time"]: parseTime($args["--time"]) else: now,
if args["--time"]: parseTime($args["--time"])
else: getLocalTime(getTime()),
summary: prevMark.summary, summary: prevMark.summary,
notes: prevMark.notes, notes: prevMark.notes,
tags: prevMark.tags) tags: prevMark.tags)
timeline.marks.add(newMark) timeline.marks.add(newMark)
writeMarks(marks = @[newMark], includeNotes = args["--verbose"]) timeline.writeMarks(
indices = @[timeline.marks.len - 1],
includeNotes = args["--verbose"])
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: time: if args["--time"]: parseTime($args["--time"]) else: now,
if args["--time"]: parseTime($args["--time"])
else: getLocalTime(getTime()),
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)
let prevLastIdx = timeline.marks.getLastIndex()
timeline.marks.add(newMark)
timeline.writeMarks(
indices = if prevLastIdx < 0: @[0]
else: @[prevLastIdx, timeline.marks.len - 1],
includeNotes = args["--verbose"])
saveTimeline(timeline, timelineLocation)
if args["resume"]:
var markToResumeIdx: int
if args["<id>"]:
markToResumeIdx = timeline.marks.findById($args["<id>"])
if markToResumeIdx == -1: exitErr "Cannot find a mark matching " & $args["<id>"]
else:
markToResumeIdx = timeline.marks.getLastIndex()
if markToResumeIdx < 0: exitErr "No mark to resume."
var markToResume = timeline.marks[markToResumeIdx]
var newMark: Mark = (
id: genUUID(),
time: if args["--time"]: parseTime($args["--time"]) else: now,
summary: markToResume.summary,
notes: markToResume.notes,
tags: markToResume.tags)
if args["--edit"]: edit(newMark) if args["--edit"]: edit(newMark)
timeline.marks.add(newMark) timeline.marks.add(newMark)
writeMarks(marks = @[newMark], includeNotes = args["--verbose"]) timeline.writeMarks(
indices = sequtils.toSeq(markToResumeIdx..<timeline.marks.len),
includeNotes = args["--verbose"])
saveTimeline(timeline, timelineLocation) saveTimeline(timeline, timelineLocation)
if args["amend"]: if args["amend"]:
# Note, this returns a copy, not a reference to the mark in the seq. # Note, this returns a copy, not a reference to the mark in the seq.
let markIdx = timeline.marks.findById($args["<id>"]) var markIdx: int
if args["<id>"]:
markIdx = timeline.marks.findById($args["<id>"])
if markIdx == -1: exitErr "Cannot find a mark matching " & $args["<id>"]
else:
markIdx = timeline.marks.getLastIndex()
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"]:
@ -391,10 +491,14 @@ Options:
if args["--edit"]: edit(mark) if args["--edit"]: edit(mark)
writeMarks(marks = @[mark], includeNotes = args["--verbose"])
timeline.marks.delete(markIdx) timeline.marks.delete(markIdx)
timeline.marks.insert(mark, markIdx) timeline.marks.insert(mark, markIdx)
timeline.writeMarks(
indices = @[markIdx],
includeNotes = args["--verbose"])
saveTimeline(timeline, timelineLocation) saveTimeline(timeline, timelineLocation)
if args["delete"]: if args["delete"]:
@ -405,28 +509,30 @@ Options:
if args["list"] or args["ls"]: if args["list"] or args["ls"]:
var marks = timeline.marks if args["tags"]:
if args["--after"]: echo $(timeline.marks.mapIt(it.tags)
var startTime: TimeInfo .flatten().deduplicate().sorted(system.cmp).join("\n"))
try: startTime = parseTime($args["--after"])
except: raise newException(ValueError,
"invalid value for --after: " & getCurrentExceptionMsg())
marks = marks.filter(proc(m: Mark): bool = m.time > startTime)
if args["--before"]: else:
var endTime: TimeInfo var selectedIndices = timeline.filterMarkIndices(args)
try: endTime = parseTime($args["--before"])
except: raise newException(ValueError,
"invalid value for --before: " & getCurrentExceptionMsg())
marks = marks.filter(proc(m: Mark): bool = m.time < endTime)
marks = marks.sorted(proc(a, b: Mark): int = cmp(a.time, b.time)) timeline.writeMarks(
indices = selectedIndices,
includeNotes = args["--verbose"])
writeMarks(marks = marks, includeNotes = args["--version"]) 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[TimeInterval] = @[]
if args["--ids"]: if args["--ids"]:
@ -435,57 +541,36 @@ Options:
if markIdx == -1: if markIdx == -1:
warn "ptk: could not find mark for id " & id warn "ptk: could not find mark for id " & id
elif markIdx == timeline.marks.len - 1: elif markIdx == timeline.marks.len - 1:
intervals.add(getLocalTime(getTime()) - timeline.marks.last.time) intervals.add(now - timeline.marks.last.time)
else: else:
intervals.add(timeline.marks[markIdx + 1].time - timeline.marks[markIdx].time) intervals.add(timeline.marks[markIdx + 1].time - timeline.marks[markIdx].time)
else: else:
var startIdx = 0 var indicesToSum = timeline.filterMarkIndices(args)
var endIdx = timeline.marks.len - 1
if args["<firstId>"]: for idx in indicesToSum:
startIdx = max(timeline.marks.findById($args["<firstId>"]), 0) let mark = timeline.marks[idx]
if idx == timeline.marks.len - 1: intervals.add(now - mark.time)
if args["<lastId>"]: else: intervals.add(timeline.marks[idx + 1].time - mark.time)
let idx = timeline.marks.findById($args["<firstId>"])
if (idx > 0): endIdx = idx
if args["--after"]:
var startTime: TimeInfo
try: startTime = parseTime($args["--after"])
except: raise newException(ValueError,
"invalid value for --after: " & getCurrentExceptionMsg())
let marks = timeline.marks.filter(proc(m: Mark): bool = m.time > startTime)
let idx = timeline.marks.findById($marks.first.id)
if idx > startIdx: startIdx = idx
if args["--before"]:
var endTime: TimeInfo
try: endTime = parseTime($args["--before"])
except: raise newException(ValueError,
"invalid value for --after: " & getCurrentExceptionMsg())
let marks = timeline.marks.filter(proc(m: Mark): bool = m.time < endTime)
let idx = timeline.marks.findById($marks.last.id)
if idx < endIdx: endIdx = idx
for idx in startIdx..<min(endIdx, timeline.marks.len - 1):
if timeline.marks[idx].summary == STOP_MSG: continue # don't count stops
intervals.add(timeline.marks[idx + 1].time - timeline.marks[idx].time)
if endIdx == timeline.marks.len - 1 and
timeline.marks.last.summary != STOP_MSG:
intervals.add(getLocalTime(getTime()) - timeline.marks.last.time)
if intervals.len == 0: if intervals.len == 0:
echo "ptk: no marks found" echo "ptk: no marks found"
else: else:
let total = foldl(intervals, a + b) let total = intervals.foldl(a + b)
echo total.flexFormat 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.2.1" 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.15.0", "docopt >= 0.6.4", "uuids", "langutils", "tempfile", "timeutils"] 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"]

View File

@ -1,3 +0,0 @@
template first*(s: seq): auto = s[0]
template last*(s: seq): auto = s[len(s)-1]