10 Commits

Author SHA1 Message Date
15708cebdf Add explicit dependency on isaac >= 0.1.2
Something depends on isaac 0.1.0, but that version doesn't compile on Nim 0.16
and above. Until the transitive dependancy is updated, ask for at least 0.1.2.
2017-02-21 11:18:12 -06:00
78a35b6478 Added list tags command. 2017-02-09 10:57:51 -06:00
099848edca Add resume command, update documentation. 2016-12-13 12:29:03 -06:00
d3fc1cdf9c Fix incorrect ISO time format.
An incorrect time format that was used in version 0.6 and prior.
Version 0.7 between 1.0 support this format on read only and will write
out the correct  format (so they can be used to convert older timelines).
2016-11-01 09:57:36 -05:00
237f5026f2 Added merge command. 2016-10-23 17:31:27 -05:00
3cf76ef382 Add --this-week, --last-week options 2016-10-21 15:42:48 -05:00
e0618f6520 Add some alternate time formats. 2016-10-13 06:31:51 -05:00
915c5b1ea1 Added --today flag. 2016-10-12 16:30:32 -05:00
9d0c77c8af Add support for using tags and pattern matching to select marks to list or sum. 2016-10-11 16:13:26 -05:00
033862f793 Added README and TODO. 2016-10-11 13:29:31 -05:00
6 changed files with 282 additions and 115 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.

8
private/ptkutil.nim Normal file
View File

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

367
ptk.nim
View File

@ -3,26 +3,35 @@
##
## Simple time keeping CLI
import algorithm, docopt, json, langutils, logging, os, sequtils, strutils,
tempfile, terminal, times, timeutils, uuids
import ptkutil
import algorithm, docopt, json, langutils, logging, os, nre, sequtils,
sets, strutils, tempfile, terminal, times, timeutils, uuids
import private/ptkutil
type
Mark* = tuple[id: UUID, time: TimeInfo, 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.
const STOP_MSG = "STOP"
let NO_MARK: Mark = (
id: parseUUID("00000000-0000-0000-0000-000000000000"),
time: getLocalTime(getTime()),
time: fromSeconds(0).getLocalTime,
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"
## The canonical time format used by PTK.
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'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" ]
## Other time formats that PTK will accept as input.
#proc `$`*(mark: Mark): string =
#return (($mark.uuid)[
@ -32,6 +41,7 @@ proc exitErr(msg: string): void =
quit(QuitFailure)
proc parseTime(timeStr: string): TimeInfo =
## Helper to parse time strings trying multiple known formats.
for fmt in TIME_FORMATS:
try: return parse(timeStr, fmt)
except: discard nil
@ -51,6 +61,10 @@ 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:
@ -60,16 +74,29 @@ proc loadTimeline(filename: string): Timeline =
var timeline: Timeline = (name: timelineJson["name"].getStr(), marks: @[])
for markJson in timelineJson["marks"]:
# TODO: an incorrect time format that was used in version 0.6 and prior.
# Version 0.7 between 1.0 support this format on read only and will write
# out the correct format (so they can be used to convert older timelines).
var time: TimeInfo
try: time = parse(markJson["time"].getStr(), ISO_TIME_FORMAT)
except: time = parse(markJson["time"].getStr(), "yyyy:MM:dd'T'HH:mm:ss")
timeline.marks.add((
id: parseUUID(markJson["id"].getStr()),
time: parse(markJson["time"].getStr(), ISO_TIME_FORMAT),
time: 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 =
## Write the timeline to disk at the file location given.
var timelineFile: File
try:
timelineFile = open(location, fmWrite)
@ -78,6 +105,8 @@ proc saveTimeline(timeline: Timeline, location: string): void =
finally: close(timelineFile)
proc flexFormat(i: TimeInterval): 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'"
@ -86,57 +115,72 @@ proc flexFormat(i: TimeInterval): string =
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 =
## Write a nicely-formatted list of Marks to stdout.
let marks = timeline.marks
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 =
if now - marks.first.time > 1.years: "yyyy-MM-dd HH:mm"
elif now - marks.first.time > 7.days: "MMM dd HH:mm"
elif now - marks.first.time > 1.days: "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 intervals: seq[TimeInterval] = @[]
for i in 0..<marks.len - 1: intervals.add(marks[i+1].time - marks[i].time)
intervals.add(now - marks.last.time)
var toWrite: seq[WriteData] = @[]
var prefixLens: seq[int] = @[]
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
for i in 0..<marks.len:
let mark = marks[i]
for w in toWrite:
if w.mark.summary == STOP_MSG: continue
if mark.summary == STOP_MSG: continue
let duration = intervals[i].flexFormat
setForegroundColor(stdout, fgBlack, true)
write(stdout, ($mark.id)[0..<8])
write(stdout, ($w.mark.id)[0..<8])
setForegroundColor(stdout, fgYellow)
write(stdout, " " & mark.time.format(timeFormat))
write(stdout, " " & w.mark.time.format(timeFormat))
setForegroundColor(stdout, fgCyan)
write(stdout, " (" & duration & ")")
write(stdout, " (" & w.interval.flexFormat & ")")
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)
write(stdout, " (" & mark.tags.join(", ") & ")")
write(stdout, " (" & w.mark.tags.join(", ") & ")")
resetAttributes(stdout)
writeLine(stdout, "")
if includeNotes and len(mark.notes.strip) > 0:
writeLine(stdout, spaces(longestPrefix) & mark.notes)
if includeNotes and len(w.mark.notes.strip) > 0:
writeLine(stdout, spaces(longestPrefix) & w.mark.notes)
writeLine(stdout, "")
proc formatMark(mark: Mark, nextMark = NO_MARK, timeFormat = ISO_TIME_FORMAT, includeNotes = false): string =
## Pretty-format a Mark, optionally taking the next Mark in the timeline (to
## compute duration) and a time format string, and conditionally including
## the Mark's notes in the output.
let nextTime =
if nextMark == NO_MARK: getLocalTime(getTime())
@ -163,7 +207,17 @@ proc findById(marks: seq[Mark], id: string): int =
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
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()
@ -182,6 +236,9 @@ proc doInit(timelineLocation: string): void =
type ExpectedMarkPart = enum Time, Summary, Tags, Notes
proc edit(mark: var Mark): void =
## Interactively edit a mark using the editor named in the environment
## variable "EDITOR"
var
tempFile: File
tempFileName: string
@ -215,6 +272,67 @@ proc edit(mark: var Mark): void =
finally: close(tempFile)
proc filterMarkIndices(timeline: Timeline, args: Table[string, Value]): seq[int] =
## Filter down a set of marks according to options provided in command line
## arguments.
let marks = timeline.marks
result = sequtils.toSeq(0..<marks.len).filterIt(marks[it].summary != STOP_MSG)
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:
try:
let doc = """
@ -222,11 +340,14 @@ Usage:
ptk init [options]
ptk add [options]
ptk add [options] <summary>
ptk amend [options] <id> [<summary>]
ptk resume [options] [<id>]
ptk amend [options] [<id>] [<summary>]
ptk merge <timeline> [<timeline>...]
ptk stop [options]
ptk continue
ptk delete <id>
ptk (list | ls) [options]
ptk (list | ls) tags
ptk sum-time --ids <ids>...
ptk sum-time [options] [<firstId>] [<lastId>]
ptk (-V | --version)
@ -244,17 +365,22 @@ Options:
-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.
-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.
-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.
"""
# TODO: add ptk delete [options]
logging.addHandler(newConsoleLogger())
let now = getLocalTime(getTime())
# Parse arguments
let args = docopt(doc, version = "ptk 0.2.1")
let args = docopt(doc, version = "ptk 0.10.0")
if args["--echo-args"]: echo $args
@ -300,6 +426,30 @@ Options:
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).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:
if not fileExists(timelineLocation):
@ -312,16 +462,15 @@ Options:
let newMark: Mark = (
id: genUUID(),
time:
if args["--time"]: parseTime($args["--time"])
else: getLocalTime(getTime()),
time: if args["--time"]: parseTime($args["--time"]) else: now,
summary: STOP_MSG,
notes: args["--notes"] ?: "",
tags: (args["--tags"] ?: "").split({',', ';'}))
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"])
echo "stopped timer"
@ -331,23 +480,23 @@ Options:
if timeline.marks.last.summary != STOP_MSG:
echo "There is already something in progress:"
writeMarks(
marks = @[timeline.marks.last],
timeline.writeMarks(
indices = @[timeline.marks.len - 1],
includeNotes = args["--verbose"])
quit(0)
let prevMark = timeline.marks[timeline.marks.len - 2]
var newMark: Mark = (
id: genUUID(),
time:
if args["--time"]: parseTime($args["--time"])
else: getLocalTime(getTime()),
time: if args["--time"]: parseTime($args["--time"]) else: now,
summary: prevMark.summary,
notes: prevMark.notes,
tags: prevMark.tags)
timeline.marks.add(newMark)
writeMarks(marks = @[newMark], includeNotes = args["--verbose"])
timeline.writeMarks(
indices = @[timeline.marks.len - 1],
includeNotes = args["--verbose"])
saveTimeline(timeline, timelineLocation)
@ -355,24 +504,57 @@ Options:
var newMark: Mark = (
id: genUUID(),
time:
if args["--time"]: parseTime($args["--time"])
else: getLocalTime(getTime()),
time: if args["--time"]: parseTime($args["--time"]) else: now,
summary: args["<summary>"] ?: "",
notes: args["--notes"] ?: "",
tags: (args["--tags"] ?: "").split({',', ';'}))
if args["--edit"]: edit(newMark)
let prevLastIdx = timeline.marks.getLastIndex()
timeline.marks.add(newMark)
writeMarks(marks = @[newMark], includeNotes = args["--verbose"])
timeline.writeMarks(
indices = @[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()
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"]: 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.
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()
var mark = timeline.marks[markIdx]
if args["<summary>"]: mark.summary = $args["<summary>"]
@ -391,10 +573,14 @@ Options:
if args["--edit"]: edit(mark)
writeMarks(marks = @[mark], includeNotes = args["--verbose"])
timeline.marks.delete(markIdx)
timeline.marks.insert(mark, markIdx)
timeline.writeMarks(
indices = @[markIdx],
includeNotes = args["--verbose"])
saveTimeline(timeline, timelineLocation)
if args["delete"]:
@ -405,25 +591,17 @@ Options:
if args["list"] or args["ls"]:
var marks = timeline.marks
if args["tags"]:
if args["--after"]:
var startTime: TimeInfo
try: startTime = parseTime($args["--after"])
except: raise newException(ValueError,
"invalid value for --after: " & getCurrentExceptionMsg())
marks = marks.filter(proc(m: Mark): bool = m.time > startTime)
echo $(timeline.marks.mapIt(it.tags)
.flatten().deduplicate().sorted(system.cmp).join("\n"))
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)
else:
var selectedIndices = timeline.filterMarkIndices(args)
marks = marks.sorted(proc(a, b: Mark): int = cmp(a.time, b.time))
writeMarks(marks = marks, includeNotes = args["--version"])
timeline.writeMarks(
indices = selectedIndices,
includeNotes = args["--version"])
if args["sum-time"]:
@ -435,56 +613,25 @@ Options:
if markIdx == -1:
warn "ptk: could not find mark for id " & id
elif markIdx == timeline.marks.len - 1:
intervals.add(getLocalTime(getTime()) - timeline.marks.last.time)
intervals.add(now - timeline.marks.last.time)
else:
intervals.add(timeline.marks[markIdx + 1].time - timeline.marks[markIdx].time)
else:
var startIdx = 0
var endIdx = timeline.marks.len - 1
var indicesToSum = timeline.filterMarkIndices(args)
if args["<firstId>"]:
startIdx = max(timeline.marks.findById($args["<firstId>"]), 0)
if args["<lastId>"]:
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)
for idx in indicesToSum:
let mark = timeline.marks[idx]
if idx == timeline.marks.len - 1: intervals.add(now - mark.time)
else: intervals.add(timeline.marks[idx + 1].time - mark.time)
if intervals.len == 0:
echo "ptk: no marks found"
else:
let total = foldl(intervals, a + b)
echo total.flexFormat
let total = intervals.foldl(a + b)
echo flexFormat(total)
except:
fatal "ptk: " & getCurrentExceptionMsg()

View File

@ -1,6 +1,6 @@
# Package
version = "0.2.1"
version = "0.10.0"
author = "Jonathan Bernard"
description = "Personal Time Keeper"
license = "MIT"
@ -8,5 +8,5 @@ bin = @["ptk"]
# 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", "isaac >= 0.1.2"]

View File

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