10 Commits
1.0.0 ... 1.0.9

5 changed files with 125 additions and 67 deletions

View File

@ -2,7 +2,7 @@
## ===================================
import asyncdispatch, base64, bcrypt, cliutils, docopt, jester, json, logging,
ospaths, sequtils, strutils, os, times, uuids
sequtils, strutils, os, tables, times, uuids
import nre except toSeq
@ -37,10 +37,25 @@ proc loadApiConfig*(json: JsonNode): PtkApiCfg =
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 sets up the 401 response
## correctly. The calling context needs only to return from the route.
## If the request is not authorized, this template immediately returns a
## 401 Unauthotized response
var authed {.inject.} = false
var user {.inject.}: PtkUser = PtkUser()
@ -67,13 +82,12 @@ template checkAuth(cfg: PtkApiCfg) =
except:
stderr.writeLine "Auth failed: " & getCurrentExceptionMsg()
response.data[0] = CallbackAction.TCActionSend
response.data[1] = Http401
response.data[2]["WWW_Authenticate"] = "Basic"
response.data[2]["Content-Type"] = TXT
response.data[3] = getCurrentExceptionMsg()
halt(
Http401,
@{"Content-Type": TXT, "WWW_Authenticate": "Basic" },
getCurrentExceptionMsg())
proc parseAndRun(user: PtkUser, cmd: string, params: StringTableRef): string =
proc parseAndRun(user: PtkUser, cmd: string, params: Table[string, string]): string =
var args = queryParamsToCliArgs(params)
args = @[cmd, "--file", user.timelinePath] & args
@ -124,25 +138,25 @@ proc start_api*(cfg: PtkApiCfg) =
get "/version": resp("ptk v" & PTK_VERSION, TXT)
get "/marks":
checkAuth(cfg); if not authed: return true
checkAuth(cfg)
try: resp(parseAndRun(user, "list", request.params), TXT)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/continue":
checkAuth(cfg); if not authed: return true
checkAuth(cfg)
try: resp(parseAndRun(user, "continue", request.params), TXT)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/sum-time":
checkAuth(cfg); if not authed: return true
checkAuth(cfg)
try: resp(parseAndRun(user, "sum-time", request.params), TXT)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/mark":
checkAuth(cfg); if not authed: return true
checkAuth(cfg)
var newMark: Mark
try: newMark = apiParseMark(parseJson(request.body))
@ -156,7 +170,7 @@ proc start_api*(cfg: PtkApiCfg) =
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/stop":
checkAuth(cfg); if not authed: return true
checkAuth(cfg)
var newMark: Mark
try:
@ -174,7 +188,7 @@ proc start_api*(cfg: PtkApiCfg) =
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/resume/@id":
checkAuth(cfg); if not authed: return true
checkAuth(cfg)
var timeline: Timeline
try: timeline = loadTimeline(user.timelinePath)
@ -200,7 +214,7 @@ proc start_api*(cfg: PtkApiCfg) =
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/amend/@id":
checkAuth(cfg); if not authed: return true
checkAuth(cfg)
try:
var timeline = loadTimeline(user.timelinePath)
@ -216,7 +230,7 @@ proc start_api*(cfg: PtkApiCfg) =
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/users":
checkAuth(cfg); if not authed: return true
checkAuth(cfg)
if not user.isAdmin: resp(Http403, "insufficient permission", TXT)
var newUser: PtkUser
@ -229,7 +243,7 @@ proc start_api*(cfg: PtkApiCfg) =
newUser.timelinePath = cfg.dataDir / newUser.username & ".timeline.json"
try:
discard parseAndRun(newUser, "init", newStringTable())
discard parseAndRun(newUser, "init", initTable[string,string]())
# TODO: save updated config!
# cfg.users.add(newUser)
resp(Http200, "ok", TXT)

View File

@ -9,6 +9,8 @@ type
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 = (
@ -20,11 +22,18 @@ const ISO_TIME_FORMAT* = "yyyy-MM-dd'T'HH:mm:ss"
## The canonical time format used by PTK.
const TIME_FORMATS* = @[
"yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd HH:mm",
"MM-dd'T'HH:mm:ss", "MM-dd HH:mm:ss",
"MM-dd'T'HH:mm", "MM-dd HH:mm",
"HH:mm:ss", "H:mm:ss", "H:mm", "HH:mm" ]
(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: "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 =
@ -40,7 +49,23 @@ proc getIfExists*(n: JsonNode, key: string): JsonNode =
proc parseTime*(timeStr: string): DateTime =
## Helper to parse time strings trying multiple known formats.
for fmt in TIME_FORMATS:
try: return parse(timeStr, fmt)
try:
let now = now()
let parsed = parse(timeStr, fmt.fmtStr)
case fmt.offsetFrom:
of OffsetFrom.None:
return parsed
of OffsetFrom.Year:
return initDateTime(parsed.monthday, parsed.month, now.year,
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)
@ -112,5 +137,3 @@ proc getLastIndex*(marks: seq[Mark]): int =
while idx >= 0 and marks[idx].summary == STOP_MSG: idx -= 1
if idx < 0: result = -1
else: result = idx

View File

@ -1 +1 @@
const PTK_VERSION* = "1.0.0"
const PTK_VERSION* = "1.0.9"

82
ptk.nim
View File

@ -3,8 +3,10 @@
##
## Simple time keeping CLI
import algorithm, docopt, json, langutils, logging, os, nre, sequtils,
sets, 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 timeutils except `-`
import private/util
import private/api
@ -18,18 +20,18 @@ proc exitErr(msg: string): void =
fatal "ptk: " & msg
quit(QuitFailure)
proc flexFormat(i: TimeInterval): string =
proc flexFormat(i: Duration): string =
## Pretty-format a time interval.
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 =
## Write a nicely-formatted list of Marks to stdout.
@ -41,14 +43,13 @@ proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): vo
writeLine(stdout, "No marks match the given criteria.")
return
var idxs = indices.sorted(
proc(a, b: int): int = cmp(marks[a].time, marks[b].time))
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] = @[]
@ -57,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 =
@ -96,7 +97,7 @@ proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): vo
if includeNotes and len(w.mark.notes.strip) > 0:
writeLine(stdout, "")
let wrappedNotes = wordWrap(s = w.mark.notes,
let wrappedNotes = wrapWords(s = w.mark.notes,
maxLineWidth = colWidth)
for line in splitLines(wrappedNotes):
writeLine(stdout, spaces(notesPrefixLen) & line)
@ -121,10 +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
@ -140,22 +143,27 @@ 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] =
@ -164,15 +172,15 @@ proc filterMarkIndices(timeline: Timeline, args: Table[string, Value]): seq[int]
let marks = timeline.marks
let now = getTime().local
let allIndices = sequtils.toSeq(0..<marks.len).filterIt(marks[it].summary != STOP_MSG).toSet
let allIndices = sequtils.toSeq(0..<marks.len).filterIt(marks[it].summary != STOP_MSG).toHashSet
let union = args["--or"]
var selected =
if union: initSet[int]()
if union: initHashSet[int]()
else: allIndices
template filterMarks(curSet: HashSet[int], pred: untyped): untyped =
var res: HashSet[int] = initSet[int]()
var res: HashSet[int] = initHashSet[int]()
if union:
for mIdx {.inject.} in allIndices:
if pred: res.incl(mIdx)
@ -270,8 +278,9 @@ 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.
@ -303,11 +312,11 @@ Options:
".ptkrc", $getEnv("PTKRC"), $getEnv("HOME") & "/.ptkrc"]
var ptkrcFilename: string =
foldl(ptkrcLocations, if len(a) > 0: a elif existsFile(b): b else: "")
foldl(ptkrcLocations, if len(a) > 0: a elif fileExists(b): b else: "")
var cfg: JsonNode
var cfgFile: File
if not existsFile(ptkrcFilename):
if not fileExists(ptkrcFilename):
warn "ptk: could not find .ptkrc file."
ptkrcFilename = $getEnv("HOME") & "/.ptkrc"
try:
@ -329,7 +338,7 @@ Options:
"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"]:
@ -340,7 +349,7 @@ Options:
let filesToMerge = args["<timeline>"]
let timelines = filesToMerge.mapIt(loadTimeline(it))
let names = timelines.mapIt(it.name).toSet
let names = timelines.mapIt(it.name).toHashSet
let mergedName = sequtils.toSeq(names.items).foldl(a & " + " & b)
var merged: Timeline = (
name: mergedName,
@ -378,7 +387,7 @@ Options:
time: if args["--time"]: parseTime($args["--time"]) else: now,
summary: STOP_MSG,
notes: args["--notes"] ?: "",
tags: (args["--tags"] ?: "").split({',', ';'}).filterIt(not it.isNilOrWhitespace))
tags: (args["--tags"] ?: "").split({',', ';'}).filterIt(not it.isEmptyOrWhitespace))
timeline.marks.add(newMark)
@ -420,9 +429,9 @@ Options:
time: if args["--time"]: parseTime($args["--time"]) else: now,
summary: args["<summary>"] ?: "",
notes: args["--notes"] ?: "",
tags: (args["--tags"] ?: "").split({',', ';'}).filterIt(not it.isNilOrWhitespace))
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)
@ -452,7 +461,7 @@ Options:
notes: markToResume.notes,
tags: markToResume.tags)
if args["--edit"]: edit(newMark)
if args["--edit"]: newMark = edit(newMark)
timeline.marks.add(newMark)
timeline.writeMarks(
@ -482,14 +491,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)
@ -533,7 +541,7 @@ Options:
if args["sum-time"]:
var intervals: seq[TimeInterval] = @[]
var intervals: seq[Duration] = @[]
if args["--ids"]:
for id in args["<ids>"]:

View File

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