Compare commits
39 Commits
Author | SHA1 | Date | |
---|---|---|---|
c00be8c1fc | |||
96ee649bf6 | |||
69177ffa17 | |||
8b6405441a | |||
aff927b4f4 | |||
7c7695b891 | |||
6ab23c7c84 | |||
136e0ade02 | |||
3a4905ff6c | |||
15a893d99f | |||
a58c7923cb | |||
9de8a39d9e | |||
78480dc61c | |||
fb652eee30 | |||
a1333db427 | |||
af6aa5d520 | |||
|
0d4827453a | ||
|
03da2e9cd9 | ||
|
e62a4e31de | ||
|
5140afa671 | ||
|
56be47f7e1 | ||
|
75f74c3a0a | ||
|
e5c6e6187c | ||
|
81d326c5c8 | ||
|
72c332fa45 | ||
|
4a878026d8 | ||
|
ee733957c6 | ||
|
d75f5607c2 | ||
|
15708cebdf | ||
|
78a35b6478 | ||
|
099848edca | ||
|
d3fc1cdf9c | ||
|
237f5026f2 | ||
|
3cf76ef382 | ||
|
e0618f6520 | ||
|
915c5b1ea1 | ||
|
9d0c77c8af | ||
|
033862f793 | ||
|
bf5c0a5752 |
1
.tool-versions
Normal file
1
.tool-versions
Normal file
@ -0,0 +1 @@
|
|||||||
|
nim 1.6.20
|
14
README.md
Normal file
14
README.md
Normal 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.
|
253
private/api.nim
Normal file
253
private/api.nim
Normal 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
148
private/models.nim
Normal 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
11
private/util.nim
Normal 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
1
private/version.nim
Normal file
@ -0,0 +1 @@
|
|||||||
|
const PTK_VERSION* = "1.0.14"
|
591
ptk.nim
591
ptk.nim
@ -3,26 +3,15 @@
|
|||||||
##
|
##
|
||||||
## 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, std/wordwrap,
|
||||||
tempfile, terminal, times, timeutils, uuids
|
sequtils, sets, strutils, sugar, tempfile, terminal, times, uuids
|
||||||
import ptkutil
|
|
||||||
|
|
||||||
type
|
import timeutils except `-`
|
||||||
Mark* = tuple[id: UUID, time: TimeInfo, summary: string, notes: string, tags: seq[string]]
|
|
||||||
Timeline* = tuple[name: string, marks: seq[Mark]]
|
|
||||||
|
|
||||||
const STOP_MSG = "STOP"
|
import private/util
|
||||||
|
import private/api
|
||||||
let NO_MARK: Mark = (
|
import private/models
|
||||||
id: parseUUID("00000000-0000-0000-0000-000000000000"),
|
import private/version
|
||||||
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,132 +20,91 @@ 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 =
|
||||||
for fmt in TIME_FORMATS:
|
## Pretty-format a time interval.
|
||||||
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 =
|
|
||||||
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)
|
||||||
|
|
||||||
proc writeMarks(marks: seq[Mark], includeNotes = false): void =
|
type WriteData = tuple[idx: int, mark: Mark, prefixLen: int, interval: Duration]
|
||||||
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((a, b) => 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 > initDuration(days = 365): "yyyy-MM-dd HH:mm"
|
||||||
elif now - marks.first.time > 7.days: "MMM dd HH:mm"
|
elif largestInterval > initDuration(days = 7): "MMM dd HH:mm"
|
||||||
elif now - marks.first.time > 1.days: "ddd HH:mm"
|
elif largestInterval > initDuration(days = 1): "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: Duration =
|
||||||
|
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)
|
||||||
writeLine(stdout, spaces(longestPrefix - prefixLens[i]) & " -- " & mark.summary)
|
write(stdout, spaces(longestPrefix - w.prefixLen) & " -- " & w.mark.summary)
|
||||||
|
|
||||||
if includeNotes and len(mark.notes.strip) > 0:
|
if w.mark.tags.len > 0:
|
||||||
writeLine(stdout, spaces(longestPrefix) & mark.notes)
|
setForegroundColor(stdout, fgGreen)
|
||||||
|
write(stdout, " (" & w.mark.tags.join(", ") & ")")
|
||||||
|
resetAttributes(stdout)
|
||||||
|
|
||||||
|
writeLine(stdout, "")
|
||||||
|
|
||||||
|
if includeNotes and len(w.mark.notes.strip) > 0:
|
||||||
|
writeLine(stdout, "")
|
||||||
|
let wrappedNotes = wrapWords(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()
|
||||||
@ -172,7 +120,14 @@ proc doInit(timelineLocation: string): void =
|
|||||||
timelineFile.write($timeline.pretty)
|
timelineFile.write($timeline.pretty)
|
||||||
finally: close(timelineFile)
|
finally: close(timelineFile)
|
||||||
|
|
||||||
proc edit(mark: var Mark): void =
|
type ExpectedMarkPart = enum Time, Summary, Tags, Notes
|
||||||
|
|
||||||
|
proc edit(mark: Mark): Mark =
|
||||||
|
## Interactively edit a mark using the editor named in the environment
|
||||||
|
## variable "EDITOR"
|
||||||
|
|
||||||
|
result = mark
|
||||||
|
|
||||||
var
|
var
|
||||||
tempFile: File
|
tempFile: File
|
||||||
tempFileName: string
|
tempFileName: string
|
||||||
@ -180,44 +135,136 @@ proc edit(mark: var Mark): void =
|
|||||||
try:
|
try:
|
||||||
(tempFile, tempFileName) = mkstemp("timestamp-mark-", ".txt", "", fmWrite)
|
(tempFile, tempFileName) = mkstemp("timestamp-mark-", ".txt", "", fmWrite)
|
||||||
tempFile.writeLine(
|
tempFile.writeLine(
|
||||||
"""# Edit the time, mark, and notes below. Any lines starting with '#' will be
|
"""# Edit the time, mark, tags, and notes below. Any lines starting with '#' will
|
||||||
# ignored. When done, save the file and close the editor.""")
|
# be ignored. When done, save the file and close the editor.""")
|
||||||
tempFile.writeLine(mark.time.format(ISO_TIME_FORMAT))
|
tempFile.writeLine(mark.time.format(ISO_TIME_FORMAT))
|
||||||
tempFile.writeLine(mark.summary)
|
tempFile.writeLine(mark.summary)
|
||||||
|
tempFile.writeLine(mark.tags.join(","))
|
||||||
tempFile.writeLine(
|
tempFile.writeLine(
|
||||||
"""# Everything from the line below to the end of the file will be considered
|
"""# Everything from the line below to the end of the file will be considered
|
||||||
# notes for this timeline mark.""")
|
# notes for this timeline mark.""")
|
||||||
|
tempFile.write(mark.notes)
|
||||||
|
|
||||||
close(tempFile)
|
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
|
var markPart = Time
|
||||||
readTime = false
|
var notes: seq[string] = @[]
|
||||||
readSummary = false
|
|
||||||
|
|
||||||
for line in lines tempFileName:
|
for line in lines tempFileName:
|
||||||
if strip(line)[0] == '#': continue
|
if strip(line).len > 0 and strip(line)[0] == '#': continue
|
||||||
elif not readTime: mark.time = parseTime(line); readTime = true
|
elif markPart == Time: result.time = parseTime(line); markPart = Summary
|
||||||
elif not readSummary: mark.summary = line; readSummary = true
|
elif markPart == Summary: result.summary = line; markPart = Tags
|
||||||
else: mark.notes &= line
|
elif markPart == Tags:
|
||||||
|
result.tags = line.split({',', ';'});
|
||||||
|
markPart = Notes
|
||||||
|
else: notes.add(line)
|
||||||
|
|
||||||
|
result.notes = notes.join("\n")
|
||||||
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).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: 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 ammend [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 [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)
|
||||||
|
|
||||||
@ -230,20 +277,28 @@ Options:
|
|||||||
-c --config <cfgFile> Use <cfgFile> as configuration for the CLI.
|
-c --config <cfgFile> Use <cfgFile> as configuration for the CLI.
|
||||||
-e --edit Open the mark in an editor.
|
-e --edit Open the mark in an editor.
|
||||||
-f --file <file> Use the given timeline file.
|
-f --file <file> Use the given timeline file.
|
||||||
-g --tags <tag> 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-tag <tag> 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.
|
-h --help Print this usage information.
|
||||||
-n --notes <notes> For add and ammend, set the notes for a time mark.
|
|
||||||
-t --time <time> For add and ammend, use this time instead of the current time.
|
-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 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.0")
|
let args = docopt(doc, version = PTK_VERSION)
|
||||||
|
|
||||||
if args["--echo-args"]: echo $args
|
if args["--echo-args"]: echo $args
|
||||||
|
|
||||||
@ -252,43 +307,61 @@ Options:
|
|||||||
quit()
|
quit()
|
||||||
|
|
||||||
# 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"]]
|
||||||
".ptkrc", $getEnv("PTKRC"), $getEnv("HOME") & "/.ptkrc"]
|
else: @[".ptkrc", $getEnv("PTKRC"), $getEnv("HOME") & "/.ptkrc"]
|
||||||
|
|
||||||
var ptkrcFilename: string =
|
let foundPtkrcLocations =
|
||||||
foldl(ptkrcLocations, if len(a) > 0: a elif existsFile(b): b else: "")
|
ptkrcLocations.filterIt(it.len > 0 and fileExists(it))
|
||||||
|
|
||||||
var cfg: JsonNode
|
var cfg: JsonNode
|
||||||
var cfgFile: File
|
if foundPtkrcLocations.len < 1:
|
||||||
if not existsFile(ptkrcFilename):
|
|
||||||
warn "ptk: could not find .ptkrc file."
|
warn "ptk: could not find .ptkrc file."
|
||||||
ptkrcFilename = $getEnv("HOME") & "/.ptkrc"
|
debug "ptk: considered the following locations:\n\t" & ptkrcLocations.join("\n\t")
|
||||||
try:
|
|
||||||
cfgFile = open(ptkrcFilename, fmWrite)
|
|
||||||
cfgFile.write("{\"timelineLogFile\": \"timeline.log.json\"}")
|
|
||||||
except: warn "ptk: could not write default .ptkrc to " & ptkrcFilename
|
|
||||||
finally: close(cfgFile)
|
|
||||||
|
|
||||||
try: cfg = parseFile(ptkrcFilename)
|
try: cfg = parseFile(foundPtkrcLocations[0])
|
||||||
except: raise newException(IOError,
|
except: raise newException(IOError,
|
||||||
"unable to read config file: " & ptkrcFilename &
|
"unable to read config file: " & foundPtkrcLocations[0] &
|
||||||
"\x0D\x0A" & getCurrentExceptionMsg())
|
"\x0D\x0A" & getCurrentExceptionMsg())
|
||||||
|
|
||||||
# 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"]
|
||||||
|
|
||||||
var timelineLocation =
|
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
|
# Execute commands
|
||||||
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).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:
|
else:
|
||||||
|
|
||||||
if not fileExists(timelineLocation):
|
if not fileExists(timelineLocation):
|
||||||
@ -299,71 +372,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.isEmptyOrWhitespace))
|
||||||
|
|
||||||
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.isEmptyOrWhitespace))
|
||||||
|
|
||||||
if args["--edit"]: edit(newMark)
|
if args["--edit"]: newMark = edit(newMark)
|
||||||
|
|
||||||
|
let prevLastIdx = timeline.marks.getLastIndex()
|
||||||
timeline.marks.add(newMark)
|
timeline.marks.add(newMark)
|
||||||
writeMarks(marks = @[newMark], includeNotes = args["--verbose"])
|
timeline.writeMarks(
|
||||||
|
indices = if prevLastIdx < 0: @[0]
|
||||||
|
else: @[prevLastIdx, timeline.marks.len - 1],
|
||||||
|
includeNotes = args["--verbose"])
|
||||||
|
|
||||||
saveTimeline(timeline, timelineLocation)
|
saveTimeline(timeline, timelineLocation)
|
||||||
|
|
||||||
if args["ammend"]:
|
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 = sequtils.toSeq(markToResumeIdx..<timeline.marks.len),
|
||||||
|
includeNotes = args["--verbose"])
|
||||||
|
|
||||||
|
saveTimeline(timeline, timelineLocation)
|
||||||
|
|
||||||
|
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"]:
|
||||||
@ -371,22 +485,22 @@ Options:
|
|||||||
mark.tags = mark.tags.deduplicate
|
mark.tags = mark.tags.deduplicate
|
||||||
if args["--remove-tags"]:
|
if args["--remove-tags"]:
|
||||||
let tagsToRemove = (args["--remove-tags"] ?: "").split({',', ';'})
|
let tagsToRemove = (args["--remove-tags"] ?: "").split({',', ';'})
|
||||||
mark.tags = mark.tags.filter(proc (t: string): bool =
|
mark.tags = mark.tags.filter((t) => not anyIt(tagsToRemove, it == t))
|
||||||
anyIt(tagsToRemove, it == t))
|
|
||||||
if args["--time"]:
|
if args["--time"]:
|
||||||
try: mark.time = parseTime($args["--time"])
|
try: mark.time = parseTime($args["--time"])
|
||||||
except: raise newException(ValueError,
|
except: raise newException(ValueError,
|
||||||
"invalid value for --time: " & getCurrentExceptionMsg())
|
"invalid value for --time: " & getCurrentExceptionMsg())
|
||||||
|
|
||||||
if args["--edit"]: edit(mark)
|
if args["--edit"]: mark = edit(mark)
|
||||||
|
|
||||||
echo formatMark(
|
|
||||||
mark = mark,
|
|
||||||
timeFormat = "HH:mm",
|
|
||||||
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"]:
|
||||||
@ -395,31 +509,33 @@ Options:
|
|||||||
timeline.marks.delete(markIdx)
|
timeline.marks.delete(markIdx)
|
||||||
saveTimeline(timeline, timelineLocation)
|
saveTimeline(timeline, timelineLocation)
|
||||||
|
|
||||||
if args["list"]:
|
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[Duration] = @[]
|
||||||
|
|
||||||
if args["--ids"]:
|
if args["--ids"]:
|
||||||
for id in args["<ids>"]:
|
for id in args["<ids>"]:
|
||||||
@ -427,57 +543,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)
|
||||||
|
18
ptk.nimble
18
ptk.nimble
@ -1,6 +1,6 @@
|
|||||||
# Package
|
# Package
|
||||||
|
|
||||||
version = "0.2.0"
|
version = "1.0.14"
|
||||||
author = "Jonathan Bernard"
|
author = "Jonathan Bernard"
|
||||||
description = "Personal Time Keeper"
|
description = "Personal Time Keeper"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@ -8,5 +8,19 @@ bin = @["ptk"]
|
|||||||
|
|
||||||
# Dependencies
|
# 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'"
|
@ -1,3 +0,0 @@
|
|||||||
template first*(s: seq): auto = s[0]
|
|
||||||
|
|
||||||
template last*(s: seq): auto = s[len(s)-1]
|
|
Loading…
x
Reference in New Issue
Block a user