8 Commits

Author SHA1 Message Date
ee733957c6 Add current command. Reformat output of notes. 2017-09-19 10:37:28 -05:00
d75f5607c2 Fix edge case when no marks exist yet. 2017-05-16 12:32:59 -05:00
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
4 changed files with 177 additions and 31 deletions

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)

193
ptk.nim
View File

@ -4,13 +4,16 @@
## Simple time keeping CLI ## Simple time keeping CLI
import algorithm, docopt, json, langutils, logging, os, nre, sequtils, import algorithm, docopt, json, langutils, logging, os, nre, sequtils,
strutils, tempfile, terminal, times, timeutils, uuids sets, strutils, tempfile, terminal, times, timeutils, uuids
import ptkutil import private/ptkutil
type type
Mark* = tuple[id: UUID, time: TimeInfo, summary: string, notes: string, tags: seq[string]] 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]] Timeline* = tuple[name: string, marks: seq[Mark]]
## Representation of a timeline: a name and sequence of Marks.
const STOP_MSG = "STOP" const STOP_MSG = "STOP"
@ -19,12 +22,16 @@ let NO_MARK: Mark = (
time: fromSeconds(0).getLocalTime, 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"
## The canonical time format used by PTK.
const TIME_FORMATS = @[ const TIME_FORMATS = @[
"H:mm", "HH:mm", "H:mm:ss", "HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd HH:mm:ss",
"yyyy:MM:dd'T'HH:mm:ss", "yyyy:MM:dd'T'HH:mm", "yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd HH:mm",
"yyyy:MM:dd HH:mm:ss", "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 = #proc `$`*(mark: Mark): string =
#return (($mark.uuid)[ #return (($mark.uuid)[
@ -34,18 +41,13 @@ proc exitErr(msg: string): void =
quit(QuitFailure) quit(QuitFailure)
proc parseTime(timeStr: string): TimeInfo = proc parseTime(timeStr: string): TimeInfo =
## Helper to parse time strings trying multiple known formats.
for fmt in TIME_FORMATS: for fmt in TIME_FORMATS:
try: return parse(timeStr, fmt) try: return parse(timeStr, fmt)
except: discard nil except: discard nil
raise newException(Exception, "unable to interpret as a date: " & timeStr) 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 = template `%`(mark: Mark): JsonNode =
%* { %* {
"id": $(mark.id), "id": $(mark.id),
@ -59,6 +61,10 @@ template `%`(timeline: Timeline): JsonNode =
%* { "name": timeline.name, "marks": timeline.marks } %* { "name": timeline.name, "marks": timeline.marks }
proc loadTimeline(filename: string): Timeline = 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 var timelineJson: JsonNode
try: timelineJson = parseFile(filename) try: timelineJson = parseFile(filename)
except: except:
@ -68,9 +74,17 @@ proc loadTimeline(filename: string): Timeline =
var timeline: Timeline = (name: timelineJson["name"].getStr(), marks: @[]) var timeline: Timeline = (name: timelineJson["name"].getStr(), marks: @[])
for markJson in timelineJson["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(( timeline.marks.add((
id: parseUUID(markJson["id"].getStr()), 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(), summary: markJson["summary"].getStr(),
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())))
@ -81,6 +95,8 @@ proc loadTimeline(filename: string): Timeline =
return timeline return timeline
proc saveTimeline(timeline: Timeline, location: string): void = proc saveTimeline(timeline: Timeline, location: string): void =
## Write the timeline to disk at the file location given.
var timelineFile: File var timelineFile: File
try: try:
timelineFile = open(location, fmWrite) timelineFile = open(location, fmWrite)
@ -89,6 +105,8 @@ proc saveTimeline(timeline: Timeline, location: string): void =
finally: close(timelineFile) finally: close(timelineFile)
proc flexFormat(i: TimeInterval): string = proc flexFormat(i: TimeInterval): string =
## Pretty-format a time interval.
let fmt = let fmt =
if i > 1.days: "d'd' H'h' m'm'" if i > 1.days: "d'd' H'h' m'm'"
elif i >= 1.hours: "H'h' m'm'" elif i >= 1.hours: "H'h' m'm'"
@ -100,6 +118,8 @@ proc flexFormat(i: TimeInterval): string =
type WriteData = tuple[idx: int, mark: Mark, prefixLen: int, interval: TimeInterval] type WriteData = tuple[idx: int, mark: Mark, prefixLen: int, interval: TimeInterval]
proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): void = proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): void =
## Write a nicely-formatted list of Marks to stdout.
let marks = timeline.marks let marks = timeline.marks
let now = getLocalTime(getTime()) let now = getLocalTime(getTime())
@ -134,6 +154,9 @@ proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): vo
if prefix.len > longestPrefix: longestPrefix = prefix.len if prefix.len > longestPrefix: longestPrefix = prefix.len
let colWidth = 80
let notesPrefixLen = 4
for w in toWrite: for w in toWrite:
if w.mark.summary == STOP_MSG: continue if w.mark.summary == STOP_MSG: continue
@ -154,10 +177,17 @@ proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): vo
writeLine(stdout, "") writeLine(stdout, "")
if includeNotes and len(w.mark.notes.strip) > 0: if includeNotes and len(w.mark.notes.strip) > 0:
writeLine(stdout, spaces(longestPrefix) & w.mark.notes) writeLine(stdout, "")
let wrappedNotes = wordWrap(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 = 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 = let nextTime =
if nextMark == NO_MARK: getLocalTime(getTime()) if nextMark == NO_MARK: getLocalTime(getTime())
@ -167,13 +197,12 @@ proc formatMark(mark: Mark, nextMark = NO_MARK, timeFormat = ISO_TIME_FORMAT, in
# TODO: pick up here calculating the time between marks # TODO: pick up here calculating the time between marks
let prefix = ($mark.id)[0..<8] & " " & mark.time.format(timeFormat) & " (" & duration & ") -- " 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 result = prefix & mark.summary
if includeNotes and len(mark.notes.strip()) > 0: if includeNotes and len(mark.notes.strip()) > 0:
let wrappedNotes = wordWrap(s = mark.notes, maxLineWidth = 80 - prefixLen) let wrappedNotes = wordWrap(s = mark.notes, maxLineWidth = 80 - prefix.len)
for line in splitLines(wrappedNotes): for line in splitLines(wrappedNotes):
result &= "\x0D\x0A" & spaces(prefixLen) & line result &= "\x0D\x0A" & spaces(prefix.len) & line
result &= "\x0D\x0A" result &= "\x0D\x0A"
proc findById(marks: seq[Mark], id: string): int = proc findById(marks: seq[Mark], id: string): int =
@ -184,7 +213,17 @@ proc findById(marks: seq[Mark], id: string): int =
return -1 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 = 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()
@ -203,6 +242,9 @@ proc doInit(timelineLocation: string): void =
type ExpectedMarkPart = enum Time, Summary, Tags, Notes type ExpectedMarkPart = enum Time, Summary, Tags, Notes
proc edit(mark: var Mark): void = proc edit(mark: var Mark): void =
## Interactively edit a mark using the editor named in the environment
## variable "EDITOR"
var var
tempFile: File tempFile: File
tempFileName: string tempFileName: string
@ -237,6 +279,9 @@ proc edit(mark: var Mark): void =
finally: close(tempFile) finally: close(tempFile)
proc filterMarkIndices(timeline: Timeline, args: Table[string, Value]): seq[int] = 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 marks = timeline.marks
result = sequtils.toSeq(0..<marks.len).filterIt(marks[it].summary != STOP_MSG) result = sequtils.toSeq(0..<marks.len).filterIt(marks[it].summary != STOP_MSG)
@ -268,6 +313,18 @@ proc filterMarkIndices(timeline: Timeline, args: Table[string, Value]): seq[int]
let e = b + 1.days let e = b + 1.days
result = result.filterIt(marks[it].time >= b and marks[it].time < e) 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"]: if args["--tags"]:
let tags = (args["--tags"] ?: "").split({',', ';'}) let tags = (args["--tags"] ?: "").split({',', ';'})
result = result.filter(proc (i: int): bool = result = result.filter(proc (i: int): bool =
@ -289,11 +346,15 @@ Usage:
ptk init [options] ptk init [options]
ptk add [options] ptk add [options]
ptk add [options] <summary> 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 stop [options]
ptk continue ptk continue
ptk delete <id> ptk delete <id>
ptk (list | ls) [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 (-V | --version) ptk (-V | --version)
@ -314,7 +375,9 @@ Options:
-m --matching <pattern> Restric the selection to marks matching <pattern>. -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 seelction to marks during today. -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.
""" """
@ -324,7 +387,7 @@ Options:
let now = getLocalTime(getTime()) let now = getLocalTime(getTime())
# Parse arguments # Parse arguments
let args = docopt(doc, version = "ptk 0.4.1") let args = docopt(doc, version = "ptk 0.11.0")
if args["--echo-args"]: echo $args if args["--echo-args"]: echo $args
@ -370,6 +433,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):
@ -431,17 +518,55 @@ Options:
if args["--edit"]: edit(newMark) if args["--edit"]: edit(newMark)
let prevLastIdx = timeline.marks.getLastIndex()
timeline.marks.add(newMark) timeline.marks.add(newMark)
timeline.writeMarks( timeline.writeMarks(
indices = @[timeline.marks.len - 1], indices = if prevLastIdx < 0: @[0]
else: @[prevLastIdx, timeline.marks.len - 1],
includeNotes = args["--verbose"]) includeNotes = args["--verbose"])
saveTimeline(timeline, timelineLocation) 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"]: edit(newMark)
timeline.marks.add(newMark)
timeline.writeMarks(
indices = sequtils.toSeq(markToResumeIdx..<timeline.marks.len),
includeNotes = args["--verbose"])
saveTimeline(timeline, timelineLocation)
if args["amend"]: 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>"]
@ -478,11 +603,27 @@ Options:
if args["list"] or args["ls"]: if args["list"] or args["ls"]:
var selectedIndices = timeline.filterMarkIndices(args) if args["tags"]:
timeline.writeMarks( echo $(timeline.marks.mapIt(it.tags)
indices = selectedIndices, .flatten().deduplicate().sorted(system.cmp).join("\n"))
includeNotes = args["--version"])
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)
if args["sum-time"]: if args["sum-time"]:

View File

@ -1,6 +1,6 @@
# Package # Package
version = "0.4.1" version = "0.11.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", "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]