Compare commits

..

No commits in common. "main" and "0.4.1" have entirely different histories.
main ... 0.4.1

8 changed files with 186 additions and 670 deletions

View File

@ -1 +0,0 @@
nim 1.6.20

View File

@ -1,253 +0,0 @@
## 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)

View File

@ -1,148 +0,0 @@
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

View File

@ -1,11 +0,0 @@
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)

View File

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

421
ptk.nim
View File

@ -3,15 +3,28 @@
##
## Simple time keeping CLI
import algorithm, docopt, json, langutils, logging, os, nre, std/wordwrap,
sequtils, sets, strutils, sugar, tempfile, terminal, times, uuids
import algorithm, docopt, json, langutils, logging, os, nre, sequtils,
strutils, tempfile, terminal, times, timeutils, uuids
import timeutils except `-`
import ptkutil
import private/util
import private/api
import private/models
import private/version
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"]
#proc `$`*(mark: Mark): string =
#return (($mark.uuid)[
@ -20,36 +33,84 @@ proc exitErr(msg: string): void =
fatal "ptk: " & msg
quit(QuitFailure)
proc flexFormat(i: Duration): string =
## Pretty-format a time interval.
proc parseTime(timeStr: string): TimeInfo =
for fmt in TIME_FORMATS:
try: return parse(timeStr, fmt)
except: discard nil
raise newException(Exception, "unable to interpret as a date: " & timeStr)
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 > 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'"
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'"
else: "s's'"
return i.format(fmt)
type WriteData = tuple[idx: int, mark: Mark, prefixLen: int, interval: Duration]
type WriteData = tuple[idx: int, mark: Mark, prefixLen: int, interval: TimeInterval]
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
let now = getLocalTime(getTime())
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))
var idxs = indices.sorted(
proc(a, b: int): int = cmp(marks[a].time, marks[b].time))
let largestInterval = now - marks[idxs.first].time
let timeFormat =
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"
if largestInterval > 1.years: "yyyy-MM-dd HH:mm"
elif largestInterval > 7.days: "MMM dd HH:mm"
elif largestInterval > 1.days: "ddd HH:mm"
else: "HH:mm"
var toWrite: seq[WriteData] = @[]
@ -58,7 +119,7 @@ proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): vo
for i in idxs:
let
interval: Duration =
interval: TimeInterval =
if (i == marks.len - 1): now - marks[i].time
else: marks[i + 1].time - marks[i].time
prefix =
@ -73,9 +134,6 @@ 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
@ -96,15 +154,37 @@ proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): vo
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, spaces(longestPrefix) & w.mark.notes)
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()
@ -122,12 +202,7 @@ proc doInit(timelineLocation: string): 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
proc edit(mark: var Mark): void =
var
tempFile: File
tempFileName: string
@ -143,128 +218,84 @@ proc edit(mark: Mark): Mark =
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
let editor = getEnv("EDITOR", "vim")
discard os.execShellCmd editor & " " & tempFileName & " </dev/tty >/dev/tty"
discard os.execShellCmd "$EDITOR " & tempFileName & " </dev/tty >/dev/tty"
var markPart = Time
var notes: seq[string] = @[]
for line in lines tempFileName:
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
if strip(line)[0] == '#': continue
elif markPart == Time: mark.time = parseTime(line); markPart = Summary
elif markPart == Summary: mark.summary = line; markPart = Tags
elif markPart == Tags:
result.tags = line.split({',', ';'});
mark.tags = line.split({',', ';'});
markPart = Notes
else: notes.add(line)
else: mark.notes &= line & "\x0D\x0A"
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
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
result = sequtils.toSeq(0..<marks.len).filterIt(marks[it].summary != STOP_MSG)
if args["<firstId>"]:
let idx = marks.findById($args["<firstId>"])
if idx > 0: selected = selected.filterMarks(mIdx >= idx)
if idx > 0: result = result.filterIt(it >= idx)
if args["<lastId>"]:
let idx = marks.findById($args["<lastId>"])
if (idx > 0): selected = selected.filterMarks(mIdx <= idx)
if (idx > 0): result = result.filterIt(it <= idx)
if args["--after"]:
var startTime: DateTime
var startTime: TimeInfo
try: startTime = parseTime($args["--after"])
except: raise newException(ValueError,
"invalid value for --after: " & getCurrentExceptionMsg())
selected = selected.filterMarks(marks[mIdx].time > startTime)
result = result.filterIt(marks[it].time > startTime)
if args["--before"]:
var endTime: DateTime
var endTime: TimeInfo
try: endTime = parseTime($args["--before"])
except: raise newException(ValueError,
"invalid value for --before: " & getCurrentExceptionMsg())
selected = selected.filterMarks(marks[mIdx].time < endTime)
result = result.filterIt(marks[it].time < endTime)
if args["--today"]:
let now = getLocalTime(getTime())
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)
result = result.filterIt(marks[it].time >= b and marks[it].time < e)
if args["--tags"]:
let tags = (args["--tags"] ?: "").split({',', ';'})
selected = selected.filterMarks(tags.allIt(marks[mIdx].tags.contains(it)))
result = result.filter(proc (i: int): bool =
tags.allIt(marks[i].tags.contains(it)))
if args["--remove-tags"]:
let tags = (args["--remove-tags"] ?: "").split({',', ';'})
selected = selected.filterMarks(not tags.allIt(marks[mIdx].tags.contains(it)))
result = result.filter(proc (i: int): bool =
not tags.allIt(marks[i].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)
let pattern = re(args["--matching"] ?: "")
result = result.filterIt(marks[it].summary.find(pattern).isSome)
when isMainModule:
try:
let doc = """
Usage:
ptk init [options]
ptk (add | start) [options]
ptk (add | start) [options] <summary>
ptk resume [options] [<id>]
ptk amend [options] [<id>] [<summary>]
ptk merge <timeline> [<timeline>...]
ptk add [options]
ptk add [options] <summary>
ptk amend [options] <id> [<summary>]
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)
@ -278,27 +309,22 @@ 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 <tags> Remove the given tag from the selected marks.
-G --remove-tags <tagx> Remove the given tag from the selected marks.
-h --help Print this usage information.
-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)
-T --today Restrict the seelction to marks during today.
-v --verbose Include notes in timeline entry output.
"""
# TODO: add ptk delete [options]
logging.addHandler(newConsoleLogger())
let now = getTime().local
let now = getLocalTime(getTime())
# Parse arguments
let args = docopt(doc, version = PTK_VERSION)
let args = docopt(doc, version = "ptk 0.4.1")
if args["--echo-args"]: echo $args
@ -307,61 +333,43 @@ Options:
quit()
# Find and parse the .ptkrc file
let ptkrcLocations =
if args["--config"]: @[$args["--config"]]
else: @[".ptkrc", $getEnv("PTKRC"), $getEnv("HOME") & "/.ptkrc"]
let ptkrcLocations = @[
if args["--config"]: $args["<cfgFile>"] else:"",
".ptkrc", $getEnv("PTKRC"), $getEnv("HOME") & "/.ptkrc"]
let foundPtkrcLocations =
ptkrcLocations.filterIt(it.len > 0 and fileExists(it))
var ptkrcFilename: string =
foldl(ptkrcLocations, if len(a) > 0: a elif existsFile(b): b else: "")
var cfg: JsonNode
if foundPtkrcLocations.len < 1:
var cfgFile: File
if not existsFile(ptkrcFilename):
warn "ptk: could not find .ptkrc file."
debug "ptk: considered the following locations:\n\t" & ptkrcLocations.join("\n\t")
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)
try: cfg = parseFile(foundPtkrcLocations[0])
try: cfg = parseFile(ptkrcFilename)
except: raise newException(IOError,
"unable to read config file: " & foundPtkrcLocations[0] &
"unable to read config file: " & ptkrcFilename &
"\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 fileExists(b): b else: "")
foldl(timelineLocations, if len(a) > 0: a elif existsFile(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):
@ -372,30 +380,26 @@ 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({',', ';'}).filterIt(not it.isEmptyOrWhitespace))
tags: (args["--tags"] ?: "").split({',', ';'}))
timeline.marks.add(newMark)
timeline.writeMarks(
indices = @[timeline.marks.len - 2],
includeNotes = args["--verbose"])
echo "ptk: stopped timer"
echo "stopped timer"
saveTimeline(timeline, timelineLocation)
if args["continue"]:
if timeline.marks.last.summary != STOP_MSG:
echo "ptk: there is already something in progress:"
echo "There is already something in progress:"
timeline.writeMarks(
indices = @[timeline.marks.len - 1],
includeNotes = args["--verbose"])
@ -416,50 +420,20 @@ Options:
saveTimeline(timeline, timelineLocation)
if args["add"] or args["start"]:
if args["add"]:
var newMark: Mark = (
id: genUUID(),
time: if args["--time"]: parseTime($args["--time"]) else: now,
summary: args["<summary>"] ?: "",
notes: args["--notes"] ?: "",
tags: (args["--tags"] ?: "").split({',', ';'}).filterIt(not it.isEmptyOrWhitespace))
tags: (args["--tags"] ?: "").split({',', ';'}))
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)
if args["--edit"]: edit(newMark)
timeline.marks.add(newMark)
timeline.writeMarks(
indices = sequtils.toSeq(markToResumeIdx..<timeline.marks.len),
indices = @[timeline.marks.len - 1],
includeNotes = args["--verbose"])
saveTimeline(timeline, timelineLocation)
@ -467,17 +441,9 @@ Options:
if args["amend"]:
# Note, this returns a copy, not a reference to the mark in the seq.
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."
let markIdx = timeline.marks.findById($args["<id>"])
var mark = timeline.marks[markIdx]
if args["<summary>"]: mark.summary = $args["<summary>"]
if args["--notes"]: mark.notes = $args["<notes>"]
if args["--tags"]:
@ -485,13 +451,14 @@ Options:
mark.tags = mark.tags.deduplicate
if args["--remove-tags"]:
let tagsToRemove = (args["--remove-tags"] ?: "").split({',', ';'})
mark.tags = mark.tags.filter((t) => not anyIt(tagsToRemove, it == t))
mark.tags = mark.tags.filter(proc (t: string): bool =
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"]: mark = edit(mark)
if args["--edit"]: edit(mark)
timeline.marks.delete(markIdx)
timeline.marks.insert(mark, markIdx)
@ -511,31 +478,15 @@ Options:
if args["list"] or args["ls"]:
if args["tags"]:
var selectedIndices = timeline.filterMarkIndices(args)
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)
timeline.writeMarks(
indices = selectedIndices,
includeNotes = args["--version"])
if args["sum-time"]:
var intervals: seq[Duration] = @[]
var intervals: seq[TimeInterval] = @[]
if args["--ids"]:
for id in args["<ids>"]:
@ -562,17 +513,7 @@ 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 = "1.0.14"
version = "0.4.1"
author = "Jonathan Bernard"
description = "Personal Time Keeper"
license = "MIT"
@ -8,19 +8,5 @@ bin = @["ptk"]
# Dependencies
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"
]
requires @["nim >= 0.15.0", "docopt >= 0.6.4", "uuids", "langutils", "tempfile", "timeutils"]
task updateVersion, "Update the version of this package.":
exec "update_nim_package_version ptk 'private/version.nim'"

3
ptkutil.nim Normal file
View File

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