6 Commits
0.2.1 ... 0.6.0

5 changed files with 186 additions and 110 deletions

14
README.md Normal file
View File

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

1
TODO.md Normal file
View File

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

273
ptk.nim
View File

@ -3,8 +3,9 @@
## ##
## Simple time keeping CLI ## Simple time keeping CLI
import algorithm, docopt, json, langutils, logging, os, sequtils, strutils, import algorithm, docopt, json, langutils, logging, os, nre, sequtils,
tempfile, terminal, times, timeutils, uuids sets, strutils, tempfile, terminal, times, timeutils, uuids
import ptkutil import ptkutil
type type
@ -15,14 +16,15 @@ const STOP_MSG = "STOP"
let NO_MARK: Mark = ( let NO_MARK: Mark = (
id: parseUUID("00000000-0000-0000-0000-000000000000"), id: parseUUID("00000000-0000-0000-0000-000000000000"),
time: getLocalTime(getTime()), time: fromSeconds(0).getLocalTime,
summary: "", notes: "", tags: @[]) summary: "", notes: "", tags: @[])
const ISO_TIME_FORMAT = "yyyy:MM:dd'T'HH:mm:ss" const ISO_TIME_FORMAT = "yyyy:MM:dd'T'HH:mm:ss"
const TIME_FORMATS = @[ const TIME_FORMATS = @[
"H:mm", "HH:mm", "H:mm:ss", "HH:mm:ss", "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'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 = #proc `$`*(mark: Mark): string =
#return (($mark.uuid)[ #return (($mark.uuid)[
@ -67,6 +69,9 @@ proc loadTimeline(filename: string): Timeline =
notes: markJson["notes"].getStr(), notes: markJson["notes"].getStr(),
tags: markJson["tags"].getElems(@[]).map(proc (t: JsonNode): string = t.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 return timeline
proc saveTimeline(timeline: Timeline, location: string): void = proc saveTimeline(timeline: Timeline, location: string): void =
@ -86,54 +91,64 @@ proc flexFormat(i: TimeInterval): string =
return i.format(fmt) return i.format(fmt)
proc writeMarks(marks: seq[Mark], includeNotes = false): void = type WriteData = tuple[idx: int, mark: Mark, prefixLen: int, interval: TimeInterval]
proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): void =
let marks = timeline.marks
let now = getLocalTime(getTime()) let now = getLocalTime(getTime())
var idxs = indices.sorted(
proc(a, b: int): int = cmp(marks[a].time, marks[b].time))
let largestInterval = now - marks[idxs.first].time
let timeFormat = let timeFormat =
if now - marks.first.time > 1.years: "yyyy-MM-dd HH:mm" if largestInterval > 1.years: "yyyy-MM-dd HH:mm"
elif now - marks.first.time > 7.days: "MMM dd HH:mm" elif largestInterval > 7.days: "MMM dd HH:mm"
elif now - marks.first.time > 1.days: "ddd HH:mm" elif largestInterval > 1.days: "ddd HH:mm"
else: "HH:mm" else: "HH:mm"
var intervals: seq[TimeInterval] = @[] var toWrite: seq[WriteData] = @[]
for i in 0..<marks.len - 1: intervals.add(marks[i+1].time - marks[i].time)
intervals.add(now - marks.last.time)
var prefixLens: seq[int] = @[]
var longestPrefix = 0 var longestPrefix = 0
for i in 0..<marks.len:
let
mark = marks[i]
interval = intervals[i]
prefix = ($mark.id)[0..<8] & " " & mark.time.format(timeFormat) & " (" & interval.flexFormat & ")"
prefixLens.add(prefix.len) for i in idxs:
let
interval: TimeInterval =
if (i == marks.len - 1): now - marks[i].time
else: marks[i + 1].time - marks[i].time
prefix =
($marks[i].id)[0..<8] & " " & marks[i].time.format(timeFormat) &
" (" & interval.flexFormat & ")"
toWrite.add((
idx: i,
mark: marks[i],
prefixLen: prefix.len,
interval: interval))
if prefix.len > longestPrefix: longestPrefix = prefix.len if prefix.len > longestPrefix: longestPrefix = prefix.len
for i in 0..<marks.len: for w in toWrite:
let mark = marks[i] if w.mark.summary == STOP_MSG: continue
if mark.summary == STOP_MSG: continue
let duration = intervals[i].flexFormat
setForegroundColor(stdout, fgBlack, true) setForegroundColor(stdout, fgBlack, true)
write(stdout, ($mark.id)[0..<8]) write(stdout, ($w.mark.id)[0..<8])
setForegroundColor(stdout, fgYellow) setForegroundColor(stdout, fgYellow)
write(stdout, " " & mark.time.format(timeFormat)) write(stdout, " " & w.mark.time.format(timeFormat))
setForegroundColor(stdout, fgCyan) setForegroundColor(stdout, fgCyan)
write(stdout, " (" & duration & ")") write(stdout, " (" & w.interval.flexFormat & ")")
resetAttributes(stdout) resetAttributes(stdout)
write(stdout, spaces(longestPrefix - prefixLens[i]) & " -- " & mark.summary) write(stdout, spaces(longestPrefix - w.prefixLen) & " -- " & w.mark.summary)
if mark.tags.len > 0: if w.mark.tags.len > 0:
setForegroundColor(stdout, fgGreen) setForegroundColor(stdout, fgGreen)
write(stdout, " (" & mark.tags.join(", ") & ")") write(stdout, " (" & w.mark.tags.join(", ") & ")")
resetAttributes(stdout) resetAttributes(stdout)
writeLine(stdout, "") writeLine(stdout, "")
if includeNotes and len(mark.notes.strip) > 0: if includeNotes and len(w.mark.notes.strip) > 0:
writeLine(stdout, spaces(longestPrefix) & mark.notes) writeLine(stdout, spaces(longestPrefix) & w.mark.notes)
writeLine(stdout, "") writeLine(stdout, "")
proc formatMark(mark: Mark, nextMark = NO_MARK, timeFormat = ISO_TIME_FORMAT, includeNotes = false): string = proc formatMark(mark: Mark, nextMark = NO_MARK, timeFormat = ISO_TIME_FORMAT, includeNotes = false): string =
@ -215,6 +230,64 @@ proc edit(mark: var Mark): void =
finally: close(tempFile) finally: close(tempFile)
proc filterMarkIndices(timeline: Timeline, args: Table[string, Value]): seq[int] =
let marks = timeline.marks
result = sequtils.toSeq(0..<marks.len).filterIt(marks[it].summary != STOP_MSG)
if args["<firstId>"]:
let idx = marks.findById($args["<firstId>"])
if idx > 0: result = result.filterIt(it >= idx)
if args["<lastId>"]:
let idx = marks.findById($args["<lastId>"])
if (idx > 0): result = result.filterIt(it <= idx)
if args["--after"]:
var startTime: TimeInfo
try: startTime = parseTime($args["--after"])
except: raise newException(ValueError,
"invalid value for --after: " & getCurrentExceptionMsg())
result = result.filterIt(marks[it].time > startTime)
if args["--before"]:
var endTime: TimeInfo
try: endTime = parseTime($args["--before"])
except: raise newException(ValueError,
"invalid value for --before: " & getCurrentExceptionMsg())
result = result.filterIt(marks[it].time < endTime)
if args["--today"]:
let now = getLocalTime(getTime())
let b = now.startOfDay
let e = b + 1.days
result = result.filterIt(marks[it].time >= b and marks[it].time < e)
if args["--this-week"]:
let now = getLocalTime(getTime())
let b = now.startOfWeek(dSun)
let e = b + 7.days
result = result.filterIt(marks[it].time >= b and marks[it].time < e)
if args["--last-week"]:
let now = getLocalTime(getTime())
let e = now.startOfWeek(dSun)
let b = e - 7.days
result = result.filterIt(marks[it].time >= b and marks[it].time < e)
if args["--tags"]:
let tags = (args["--tags"] ?: "").split({',', ';'})
result = result.filter(proc (i: int): bool =
tags.allIt(marks[i].tags.contains(it)))
if args["--remove-tags"]:
let tags = (args["--remove-tags"] ?: "").split({',', ';'})
result = result.filter(proc (i: int): bool =
not tags.allIt(marks[i].tags.contains(it)))
if args["--matching"]:
let pattern = re(args["--matching"] ?: "")
result = result.filterIt(marks[it].summary.find(pattern).isSome)
when isMainModule: when isMainModule:
try: try:
let doc = """ let doc = """
@ -223,6 +296,7 @@ Usage:
ptk add [options] ptk add [options]
ptk add [options] <summary> ptk add [options] <summary>
ptk amend [options] <id> [<summary>] 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>
@ -244,17 +318,22 @@ Options:
-g --tags <tags> Add the given tags (comma-separated) to the selected marks. -g --tags <tags> Add the given tags (comma-separated) to the selected marks.
-G --remove-tags <tagx> Remove the given tag from the selected marks. -G --remove-tags <tagx> Remove the given tag from the selected marks.
-h --help Print this usage information. -h --help Print this usage information.
-m --matching <pattern> Restric the selection to marks matching <pattern>.
-n --notes <notes> For add and amend, set the notes for a time mark. -n --notes <notes> For add and amend, set the notes for a time mark.
-t --time <time> For add and amend, use this time instead of the current time. -t --time <time> For add and amend, use this time instead of the current time.
-T --today Restrict the selection to marks during today.
-w --this-week Restrict the selection to marks during this week.
-W --last-week Restrict the selection to marks during the last week.
-v --verbose Include notes in timeline entry output. -v --verbose Include notes in timeline entry output.
""" """
# TODO: add ptk delete [options] # TODO: add ptk delete [options]
logging.addHandler(newConsoleLogger()) logging.addHandler(newConsoleLogger())
let now = getLocalTime(getTime())
# Parse arguments # Parse arguments
let args = docopt(doc, version = "ptk 0.2.1") let args = docopt(doc, version = "ptk 0.6.0")
if args["--echo-args"]: echo $args if args["--echo-args"]: echo $args
@ -300,6 +379,30 @@ Options:
if args["init"]: if args["init"]:
doInit(foldl(timelineLocations, if len(a) > 0: a else: b)) doInit(foldl(timelineLocations, if len(a) > 0: a else: b))
elif args["merge"]:
let filesToMerge = args["<timeline>"]
let timelines = filesToMerge.mapIt(loadTimeline(it))
let names = timelines.mapIt(it.name).toSet
let mergedName = sequtils.toSeq(names.items).foldl(a & " + " & b)
var merged: Timeline = (
name: mergedName,
marks: @[])
for timeline in timelines:
for mark in timeline.marks:
var existingMarkIdx = merged.marks.findById($mark.id)
if existingMarkIdx >= 0:
if merged.marks[existingMarkIdx].summary != mark.summary:
merged.marks[existingMarkIdx].summary &= " | " & mark.summary
if merged.marks[existingMarkIdx].notes != mark.notes:
merged.marks[existingMarkIdx].notes &= "\r\n--------\r\b" & mark.notes
else: merged.marks.add(mark)
writeLine(stdout, pretty(%merged))
else: else:
if not fileExists(timelineLocation): if not fileExists(timelineLocation):
@ -312,16 +415,15 @@ Options:
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({',', ';'}))
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 "stopped timer"
@ -331,23 +433,23 @@ Options:
if timeline.marks.last.summary != STOP_MSG: if timeline.marks.last.summary != STOP_MSG:
echo "There is already something in progress:" echo "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)
@ -355,9 +457,7 @@ Options:
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({',', ';'}))
@ -365,7 +465,9 @@ Options:
if args["--edit"]: edit(newMark) if args["--edit"]: edit(newMark)
timeline.marks.add(newMark) timeline.marks.add(newMark)
writeMarks(marks = @[newMark], includeNotes = args["--verbose"]) timeline.writeMarks(
indices = @[timeline.marks.len - 1],
includeNotes = args["--verbose"])
saveTimeline(timeline, timelineLocation) saveTimeline(timeline, timelineLocation)
@ -391,10 +493,14 @@ Options:
if args["--edit"]: edit(mark) if args["--edit"]: edit(mark)
writeMarks(marks = @[mark], includeNotes = args["--verbose"])
timeline.marks.delete(markIdx) timeline.marks.delete(markIdx)
timeline.marks.insert(mark, markIdx) timeline.marks.insert(mark, markIdx)
timeline.writeMarks(
indices = @[markIdx],
includeNotes = args["--verbose"])
saveTimeline(timeline, timelineLocation) saveTimeline(timeline, timelineLocation)
if args["delete"]: if args["delete"]:
@ -405,25 +511,11 @@ Options:
if args["list"] or args["ls"]: if args["list"] or args["ls"]:
var marks = timeline.marks var selectedIndices = timeline.filterMarkIndices(args)
if args["--after"]: timeline.writeMarks(
var startTime: TimeInfo indices = selectedIndices,
try: startTime = parseTime($args["--after"]) includeNotes = args["--version"])
except: raise newException(ValueError,
"invalid value for --after: " & getCurrentExceptionMsg())
marks = marks.filter(proc(m: Mark): bool = m.time > startTime)
if args["--before"]:
var endTime: TimeInfo
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))
writeMarks(marks = marks, includeNotes = args["--version"])
if args["sum-time"]: if args["sum-time"]:
@ -435,56 +527,25 @@ 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)
except: except:
fatal "ptk: " & getCurrentExceptionMsg() fatal "ptk: " & getCurrentExceptionMsg()

View File

@ -1,6 +1,6 @@
# Package # Package
version = "0.2.1" version = "0.6.0"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Personal Time Keeper" description = "Personal Time Keeper"
license = "MIT" license = "MIT"
@ -8,5 +8,5 @@ bin = @["ptk"]
# Dependencies # Dependencies
requires @["nim >= 0.15.0", "docopt >= 0.6.4", "uuids", "langutils", "tempfile", "timeutils"] requires @["nim >= 0.15.0", "docopt >= 0.6.4", "uuids", "langutils", "tempfile", "timeutils >= 0.2.0"]

View File

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