Compare commits

...

34 Commits
0.4.1 ... main

Author SHA1 Message Date
c00be8c1fc Add asdf tool-versions definitions pinning this to Nim 1.6 2024-05-10 11:59:58 -05:00
96ee649bf6 Change the logic around how we load our config.
The previous logic that was intended to find the first non-empty
filename that represented a valid file location was wrong. Replaced with
a simpler filter-based version.

Additionally, if the user provides a specific configuration filename, we
no longer fall back to the defaults if it is not found. Instead we just
err out and inform them that the file the specified was not found.
2022-04-02 08:45:30 -05:00
69177ffa17 Log locations when unable to load config.
When we can't find a config file, log all the locations we looked and
don't create a new file in $HOME/.ptkrc.

There is currently an intermittent bug in the config loading logic that
is leading to the default config file in $HOME/.ptkrc not being found
and then being overwritten with the default config. This is step one in
fixing it.
2022-03-19 08:49:26 -05:00
8b6405441a Support additional date and time formats (particularly YYYY-MM-DD). 2022-01-03 08:57:43 -06:00
aff927b4f4 Fix for Nim 1.6.2 2021-12-20 17:42:55 -06:00
7c7695b891 Update for migration from jdb-labs -> jdb-software. 2021-06-08 18:49:03 -05:00
6ab23c7c84 Remove tags option actually removes tags now. 2020-11-12 14:44:52 -06:00
136e0ade02 Edit functionality no loger relies on sub-process receiving parent ENV. 2020-11-12 10:42:00 -06:00
3a4905ff6c Fix relative time input following Nim stdlib changes. 2020-11-12 10:41:05 -06:00
15a893d99f Update to reflect stdlib changes in Nim 1.4. 2020-11-12 04:30:36 -06:00
a58c7923cb Update to reflect stdlib changes in Nim 1.2 2020-07-06 11:17:56 -05:00
9de8a39d9e Fix an error parsing empty lines when editing a mark. 2020-03-16 16:02:16 -05:00
78480dc61c Include notes when editing marks. 2020-03-16 09:47:15 -05:00
fb652eee30 Fix double-free in edit functionality (double-closing file handle). 2020-03-16 08:32:56 -05:00
a1333db427 Add helper to update version easily. 2020-02-16 00:53:36 -06:00
af6aa5d520 Update for Nim 1.0.0 2020-02-14 12:38:28 -06:00
Jonathan Bernard
0d4827453a Refactor to add API layer. 2018-10-03 04:01:29 -05:00
Jonathan Bernard
03da2e9cd9 WIP Refactor to support API. 2018-10-02 09:19:59 -05:00
Jonathan Bernard
e62a4e31de Fix --file flag, cut dead code. 2018-10-01 19:03:53 -05:00
Jonathan Bernard
5140afa671 All informational messages start with 'ptk:'. 2018-05-16 12:13:00 -05:00
Jonathan Bernard
56be47f7e1 Stop no longer appends extra stop messages if we're already stopped. 2018-05-16 11:50:08 -05:00
Jonathan Bernard
75f74c3a0a Make -m case insensitive. 2018-04-30 11:05:46 -05:00
Jonathan Bernard
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
Jonathan Bernard
81d326c5c8 Filter out empty whitespace when considering new tags. 2018-04-16 04:29:19 -05:00
Jonathan Bernard
72c332fa45 Add start as an alias for add. 2018-04-09 09:53:00 -05:00
Jonathan Bernard
4a878026d8 Bump library version to compile under Nim 0.18 2018-04-02 14:44:26 -05:00
Jonathan Bernard
ee733957c6 Add current command. Reformat output of notes. 2017-09-19 10:37:28 -05:00
Jonathan Bernard
d75f5607c2 Fix edge case when no marks exist yet. 2017-05-16 12:32:59 -05:00
Jonathan Bernard
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
Jonathan Bernard
78a35b6478 Added list tags command. 2017-02-09 10:57:51 -06:00
Jonathan Bernard
099848edca Add resume command, update documentation. 2016-12-13 12:29:03 -06:00
Jonathan Bernard
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
Jonathan Bernard
237f5026f2 Added merge command. 2016-10-23 17:31:27 -05:00
Jonathan Bernard
3cf76ef382 Add --this-week, --last-week options 2016-10-21 15:42:48 -05:00
8 changed files with 671 additions and 187 deletions

1
.tool-versions Normal file
View File

@ -0,0 +1 @@
nim 1.6.20

253
private/api.nim Normal file
View File

@ -0,0 +1,253 @@
## Personal Time Keeping API Interface
## ===================================
import asyncdispatch, base64, bcrypt, cliutils, docopt, httpcore, jester, json, logging,
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) =
## 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 headers(request).hasKey("Authorization"):
raiseEx "No auth token."
let headerVal = headers(request)["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)

148
private/models.nim Normal file
View File

@ -0,0 +1,148 @@
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.
OffsetFrom = enum Year, Month, Day, None
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* = @[
(fmtStr: "yyyy-MM-dd'T'HH:mm:sszzz", offsetFrom: OffsetFrom.None),
(fmtStr: "yyyy-MM-dd HH:mm:sszzz", offsetFrom: OffsetFrom.None),
(fmtStr: "yyyy-MM-dd'T'HH:mm:sszz", offsetFrom: OffsetFrom.None),
(fmtStr: "yyyy-MM-dd HH:mm:sszz", offsetFrom: OffsetFrom.None),
(fmtStr: "yyyy-MM-dd'T'HH:mm:ssz", offsetFrom: OffsetFrom.None),
(fmtStr: "yyyy-MM-dd HH:mm:ssz", offsetFrom: OffsetFrom.None),
(fmtStr: "yyyy-MM-dd'T'HH:mm:ss", offsetFrom: OffsetFrom.None),
(fmtStr: "yyyy-MM-dd HH:mm:ss", offsetFrom: OffsetFrom.None),
(fmtStr: "yyyy-MM-dd'T'HH:mm", offsetFrom: OffsetFrom.None),
(fmtStr: "yyyy-MM-dd HH:mm", offsetFrom: OffsetFrom.None),
(fmtStr: "yyyy-MM-dd", offsetFrom: OffsetFrom.None),
(fmtStr: "yyyy-MM", offsetFrom: OffsetFrom.None),
(fmtStr: "MM-dd'T'HH:mm:ss", offsetFrom: OffsetFrom.Year),
(fmtStr: "MM-dd HH:mm:ss", offsetFrom: OffsetFrom.Year),
(fmtStr: "MM-dd'T'HH:mm", offsetFrom: OffsetFrom.Year),
(fmtStr: "MM-dd HH:mm", offsetFrom: OffsetFrom.Year),
(fmtStr: "HH:mm:ss", offsetFrom: OffsetFrom.Day),
(fmtStr: "H:mm:ss", offsetFrom: OffsetFrom.Day),
(fmtStr: "H:mm", offsetFrom: OffsetFrom.Day),
(fmtStr: "HH:mm", offsetFrom: OffsetFrom.Day) ]
## 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.
let now = now()
for fmt in TIME_FORMATS:
try:
let parsed = parse(timeStr, fmt.fmtStr)
case fmt.offsetFrom:
of OffsetFrom.None:
return parsed
of OffsetFrom.Year:
return dateTime(now.year, parsed.month, parsed.monthday,
parsed.hour, parsed.minute, parsed.second, parsed.nanosecond,
now.timezone)
of OffsetFrom.Month:
return initDateTime(parsed.monthday, now.month, now.year,
parsed.hour, parsed.minute, parsed.second, parsed.nanosecond,
now.timezone)
of OffsetFrom.Day:
return initDateTime(now.monthday, now.month, now.year, parsed.hour,
parsed.minute, parsed.second, parsed.nanosecond, now.timezone)
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.14"

423
ptk.nim
View File

@ -3,28 +3,15 @@
##
## Simple time keeping CLI
import algorithm, docopt, json, langutils, logging, os, nre, sequtils,
strutils, tempfile, terminal, times, timeutils, uuids
import algorithm, docopt, json, langutils, logging, os, nre, std/wordwrap,
sequtils, sets, strutils, sugar, tempfile, terminal, times, uuids
import ptkutil
import timeutils except `-`
type
Mark* = tuple[id: UUID, time: TimeInfo, summary: string, notes: string, tags: seq[string]]
Timeline* = tuple[name: string, marks: seq[Mark]]
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"
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",
"yyyy:MM:dd HH:mm:ss", "yyyy:MM:dd HH:mm"]
import private/util
import private/api
import private/models
import private/version
#proc `$`*(mark: Mark): string =
#return (($mark.uuid)[
@ -33,84 +20,36 @@ proc exitErr(msg: string): void =
fatal "ptk: " & msg
quit(QuitFailure)
proc parseTime(timeStr: string): TimeInfo =
for fmt in TIME_FORMATS:
try: return parse(timeStr, fmt)
except: discard nil
proc flexFormat(i: Duration): string =
## Pretty-format a time interval.
raise newException(Exception, "unable to interpret as a date: " & timeStr)
proc startOfDay(ti: TimeInfo): TimeInfo =
result = ti
result.hour = 0
result.minute = 0
result.second = 0
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())))
timeline.marks = timeline.marks.sorted(
proc(a, b: Mark): int = cmp(a.time, b.time))
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 =
let fmt =
if i > 1.days: "d'd' H'h' m'm'"
elif i >= 1.hours: "H'h' m'm'"
elif i >= 1.minutes: "m'm' s's'"
if i > initDuration(days = 1): "d'd' H'h' m'm'"
elif i >= initDuration(hours = 1): "H'h' m'm'"
elif i >= initDuration(minutes = 1): "m'm' s's'"
else: "s's'"
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 =
let marks = timeline.marks
let now = getLocalTime(getTime())
## Write a nicely-formatted list of Marks to stdout.
var idxs = indices.sorted(
proc(a, b: int): int = cmp(marks[a].time, marks[b].time))
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((a, b) => cmp(marks[a].time, marks[b].time))
let largestInterval = now - marks[idxs.first].time
let timeFormat =
if largestInterval > 1.years: "yyyy-MM-dd HH:mm"
elif largestInterval > 7.days: "MMM dd HH:mm"
elif largestInterval > 1.days: "ddd HH:mm"
if largestInterval > initDuration(days = 365): "yyyy-MM-dd HH:mm"
elif largestInterval > initDuration(days = 7): "MMM dd HH:mm"
elif largestInterval > initDuration(days = 1): "ddd HH:mm"
else: "HH:mm"
var toWrite: seq[WriteData] = @[]
@ -119,7 +58,7 @@ proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): vo
for i in idxs:
let
interval: TimeInterval =
interval: Duration =
if (i == marks.len - 1): now - marks[i].time
else: marks[i + 1].time - marks[i].time
prefix =
@ -134,6 +73,9 @@ proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): vo
if prefix.len > longestPrefix: longestPrefix = prefix.len
let colWidth = 80
let notesPrefixLen = 4
for w in toWrite:
if w.mark.summary == STOP_MSG: continue
@ -154,37 +96,15 @@ proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): vo
writeLine(stdout, "")
if includeNotes and len(w.mark.notes.strip) > 0:
writeLine(stdout, spaces(longestPrefix) & w.mark.notes)
writeLine(stdout, "")
let wrappedNotes = wrapWords(s = w.mark.notes,
maxLineWidth = colWidth)
for line in splitLines(wrappedNotes):
writeLine(stdout, spaces(notesPrefixLen) & line)
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 =
## Interactively initialize a new timeline at the given file path.
stdout.write "Time log name [New Timeline]: "
let name = stdin.readLine()
@ -202,7 +122,12 @@ proc doInit(timelineLocation: string): void =
type ExpectedMarkPart = enum Time, Summary, Tags, Notes
proc edit(mark: var Mark): void =
proc edit(mark: Mark): Mark =
## Interactively edit a mark using the editor named in the environment
## variable "EDITOR"
result = mark
var
tempFile: File
tempFileName: string
@ -218,84 +143,128 @@ proc edit(mark: var Mark): void =
tempFile.writeLine(
"""# Everything from the line below to the end of the file will be considered
# notes for this timeline mark.""")
tempFile.write(mark.notes)
close(tempFile)
tempFile = nil
discard os.execShellCmd "$EDITOR " & tempFileName & " </dev/tty >/dev/tty"
let editor = getEnv("EDITOR", "vim")
discard os.execShellCmd editor & " " & tempFileName & " </dev/tty >/dev/tty"
var markPart = Time
var notes: seq[string] = @[]
for line in lines tempFileName:
if strip(line)[0] == '#': continue
elif markPart == Time: mark.time = parseTime(line); markPart = Summary
elif markPart == Summary: mark.summary = line; markPart = Tags
if strip(line).len > 0 and strip(line)[0] == '#': continue
elif markPart == Time: result.time = parseTime(line); markPart = Summary
elif markPart == Summary: result.summary = line; markPart = Tags
elif markPart == Tags:
mark.tags = line.split({',', ';'});
result.tags = line.split({',', ';'});
markPart = Notes
else: mark.notes &= line & "\x0D\x0A"
else: notes.add(line)
result.notes = notes.join("\n")
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
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).toHashSet
let union = args["--or"]
var selected =
if union: initHashSet[int]()
else: allIndices
template filterMarks(curSet: HashSet[int], pred: untyped): untyped =
var res: HashSet[int] = initHashSet[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: result = result.filterIt(it >= idx)
if idx > 0: selected = selected.filterMarks(mIdx >= idx)
if 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"]:
var startTime: TimeInfo
var startTime: DateTime
try: startTime = parseTime($args["--after"])
except: raise newException(ValueError,
"invalid value for --after: " & getCurrentExceptionMsg())
result = result.filterIt(marks[it].time > startTime)
selected = selected.filterMarks(marks[mIdx].time > startTime)
if args["--before"]:
var endTime: TimeInfo
var endTime: DateTime
try: endTime = parseTime($args["--before"])
except: raise newException(ValueError,
"invalid value for --before: " & getCurrentExceptionMsg())
result = result.filterIt(marks[it].time < endTime)
selected = selected.filterMarks(marks[mIdx].time < endTime)
if args["--today"]:
let now = getLocalTime(getTime())
let b = now.startOfDay
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"]:
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({',', ';'})
result = result.filter(proc (i: int): bool =
tags.allIt(marks[i].tags.contains(it)))
selected = selected.filterMarks(tags.allIt(marks[mIdx].tags.contains(it)))
if args["--remove-tags"]:
let tags = (args["--remove-tags"] ?: "").split({',', ';'})
result = result.filter(proc (i: int): bool =
not tags.allIt(marks[i].tags.contains(it)))
selected = selected.filterMarks(not tags.allIt(marks[mIdx].tags.contains(it)))
if args["--matching"]:
let pattern = re(args["--matching"] ?: "")
result = result.filterIt(marks[it].summary.find(pattern).isSome)
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:
try:
let doc = """
Usage:
ptk init [options]
ptk add [options]
ptk add [options] <summary>
ptk amend [options] <id> [<summary>]
ptk (add | start) [options]
ptk (add | start) [options] <summary>
ptk resume [options] [<id>]
ptk amend [options] [<id>] [<summary>]
ptk merge <timeline> [<timeline>...]
ptk stop [options]
ptk continue
ptk delete <id>
ptk (list | ls) [options]
ptk (list | ls) tags
ptk current
ptk sum-time --ids <ids>...
ptk sum-time [options] [<firstId>] [<lastId>]
ptk serve-api <svcCfg> [--port <port>]
ptk (-V | --version)
ptk (-h | --help)
@ -309,22 +278,27 @@ Options:
-e --edit Open the mark in an editor.
-f --file <file> Use the given timeline file.
-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 <tags> Remove the given tag from the selected marks.
-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.
-t --time <time> For add and amend, use this time instead of the current time.
-T --today Restrict the seelction 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 --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.
"""
# TODO: add ptk delete [options]
logging.addHandler(newConsoleLogger())
let now = getLocalTime(getTime())
let now = getTime().local
# Parse arguments
let args = docopt(doc, version = "ptk 0.4.1")
let args = docopt(doc, version = PTK_VERSION)
if args["--echo-args"]: echo $args
@ -333,43 +307,61 @@ Options:
quit()
# Find and parse the .ptkrc file
let ptkrcLocations = @[
if args["--config"]: $args["<cfgFile>"] else:"",
".ptkrc", $getEnv("PTKRC"), $getEnv("HOME") & "/.ptkrc"]
let ptkrcLocations =
if args["--config"]: @[$args["--config"]]
else: @[".ptkrc", $getEnv("PTKRC"), $getEnv("HOME") & "/.ptkrc"]
var ptkrcFilename: string =
foldl(ptkrcLocations, if len(a) > 0: a elif existsFile(b): b else: "")
let foundPtkrcLocations =
ptkrcLocations.filterIt(it.len > 0 and fileExists(it))
var cfg: JsonNode
var cfgFile: File
if not existsFile(ptkrcFilename):
if foundPtkrcLocations.len < 1:
warn "ptk: could not find .ptkrc file."
ptkrcFilename = $getEnv("HOME") & "/.ptkrc"
try:
cfgFile = open(ptkrcFilename, fmWrite)
cfgFile.write("{\"timelineLogFile\": \"timeline.log.json\"}")
except: warn "ptk: could not write default .ptkrc to " & ptkrcFilename
finally: close(cfgFile)
debug "ptk: considered the following locations:\n\t" & ptkrcLocations.join("\n\t")
try: cfg = parseFile(ptkrcFilename)
try: cfg = parseFile(foundPtkrcLocations[0])
except: raise newException(IOError,
"unable to read config file: " & ptkrcFilename &
"unable to read config file: " & foundPtkrcLocations[0] &
"\x0D\x0A" & getCurrentExceptionMsg())
# Find the time log file
let timelineLocations = @[
if args["--file"]: $args["<file>"] else: "",
if args["--file"]: $args["--file"] else: "",
$getEnv("PTK_FILE"),
cfg["timelineLogFile"].getStr(""),
"ptk.log.json"]
var timelineLocation =
foldl(timelineLocations, if len(a) > 0: a elif existsFile(b): b else: "")
foldl(timelineLocations, if len(a) > 0: a elif fileExists(b): b else: "")
# Execute commands
if args["init"]:
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).toHashSet
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:
if not fileExists(timelineLocation):
@ -380,26 +372,30 @@ Options:
if args["stop"]:
if timeline.marks.last.summary == STOP_MSG:
echo "ptk: no current task, nothing to stop"
quit(0)
let newMark: Mark = (
id: genUUID(),
time: if args["--time"]: parseTime($args["--time"]) else: now,
summary: STOP_MSG,
notes: args["--notes"] ?: "",
tags: (args["--tags"] ?: "").split({',', ';'}))
tags: (args["--tags"] ?: "").split({',', ';'}).filterIt(not it.isEmptyOrWhitespace))
timeline.marks.add(newMark)
timeline.writeMarks(
indices = @[timeline.marks.len - 2],
includeNotes = args["--verbose"])
echo "stopped timer"
echo "ptk: stopped timer"
saveTimeline(timeline, timelineLocation)
if args["continue"]:
if timeline.marks.last.summary != STOP_MSG:
echo "There is already something in progress:"
echo "ptk: there is already something in progress:"
timeline.writeMarks(
indices = @[timeline.marks.len - 1],
includeNotes = args["--verbose"])
@ -420,20 +416,50 @@ Options:
saveTimeline(timeline, timelineLocation)
if args["add"]:
if args["add"] or args["start"]:
var newMark: Mark = (
id: genUUID(),
time: if args["--time"]: parseTime($args["--time"]) else: now,
summary: args["<summary>"] ?: "",
notes: args["--notes"] ?: "",
tags: (args["--tags"] ?: "").split({',', ';'}))
tags: (args["--tags"] ?: "").split({',', ';'}).filterIt(not it.isEmptyOrWhitespace))
if args["--edit"]: edit(newMark)
if args["--edit"]: newMark = 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"]: newMark = edit(newMark)
timeline.marks.add(newMark)
timeline.writeMarks(
indices = @[timeline.marks.len - 1],
indices = sequtils.toSeq(markToResumeIdx..<timeline.marks.len),
includeNotes = args["--verbose"])
saveTimeline(timeline, timelineLocation)
@ -441,9 +467,17 @@ Options:
if args["amend"]:
# 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]
if args["<summary>"]: mark.summary = $args["<summary>"]
if args["--notes"]: mark.notes = $args["<notes>"]
if args["--tags"]:
@ -451,14 +485,13 @@ Options:
mark.tags = mark.tags.deduplicate
if args["--remove-tags"]:
let tagsToRemove = (args["--remove-tags"] ?: "").split({',', ';'})
mark.tags = mark.tags.filter(proc (t: string): bool =
anyIt(tagsToRemove, it == t))
mark.tags = mark.tags.filter((t) => not anyIt(tagsToRemove, it == t))
if args["--time"]:
try: mark.time = parseTime($args["--time"])
except: raise newException(ValueError,
"invalid value for --time: " & getCurrentExceptionMsg())
if args["--edit"]: edit(mark)
if args["--edit"]: mark = edit(mark)
timeline.marks.delete(markIdx)
timeline.marks.insert(mark, markIdx)
@ -478,15 +511,31 @@ Options:
if args["list"] or args["ls"]:
var selectedIndices = timeline.filterMarkIndices(args)
if args["tags"]:
timeline.writeMarks(
indices = selectedIndices,
includeNotes = args["--version"])
echo $(timeline.marks.mapIt(it.tags)
.flatten().deduplicate().sorted(system.cmp).join("\n"))
else:
var selectedIndices = timeline.filterMarkIndices(args)
timeline.writeMarks(
indices = selectedIndices,
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"]:
var intervals: seq[TimeInterval] = @[]
var intervals: seq[Duration] = @[]
if args["--ids"]:
for id in args["<ids>"]:
@ -513,7 +562,17 @@ Options:
else:
let total = intervals.foldl(a + b)
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:
fatal "ptk: " & getCurrentExceptionMsg()
quit(QuitFailure)

View File

@ -1,6 +1,6 @@
# Package
version = "0.4.1"
version = "1.0.14"
author = "Jonathan Bernard"
description = "Personal Time Keeper"
license = "MIT"
@ -8,5 +8,19 @@ bin = @["ptk"]
# Dependencies
requires @["nim >= 0.15.0", "docopt >= 0.6.4", "uuids", "langutils", "tempfile", "timeutils"]
requires @[
"nim >= 1.0.0",
"docopt >= 0.6.8",
"uuids",
"tempfile",
"isaac >= 0.1.3",
"bcrypt",
"jester 0.5.0",
"https://git.jdb-software.com/jdb/nim-lang-utils.git",
"https://git.jdb-software.com/jdb/nim-cli-utils.git >= 0.6.5",
"https://git.jdb-software.com/jdb/nim-time-utils.git >= 0.5.2",
"https://git.jdb-software.com/jdb/update-nim-package-version"
]
task updateVersion, "Update the version of this package.":
exec "update_nim_package_version ptk 'private/version.nim'"

View File

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