## Personal Time Keeper ## ==================== ## ## Simple time keeping CLI import algorithm, docopt, json, langutils, logging, os, nre, sequtils, sets, strutils, tempfile, terminal, times, timeutils, uuids import 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: fromSeconds(0).getLocalTime, summary: "", notes: "", tags: @[]) 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" ] ## Other time formats that PTK will accept as input. #proc `$`*(mark: Mark): string = #return (($mark.uuid)[ proc exitErr(msg: string): void = fatal "ptk: " & msg 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 raise newException(Exception, "unable to interpret as a date: " & timeStr) 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"]: # 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: 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) timelineFile.writeLine(pretty(%timeline)) except: raise newException(IOError, "unable to save changes to " & location) 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'" 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: 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 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] = @[] var longestPrefix = 0 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 w in toWrite: if w.mark.summary == STOP_MSG: continue setForegroundColor(stdout, fgBlack, true) write(stdout, ($w.mark.id)[0..<8]) setForegroundColor(stdout, fgYellow) write(stdout, " " & w.mark.time.format(timeFormat)) setForegroundColor(stdout, fgCyan) write(stdout, " (" & w.interval.flexFormat & ")") resetAttributes(stdout) write(stdout, spaces(longestPrefix - w.prefixLen) & " -- " & w.mark.summary) if w.mark.tags.len > 0: setForegroundColor(stdout, fgGreen) write(stdout, " (" & w.mark.tags.join(", ") & ")") resetAttributes(stdout) writeLine(stdout, "") 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()) 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 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() let timeline = %* { "name": if name.strip.len > 0: name.strip else: "New Timeline", "marks": [] } #"createdAt": getLocalTime().format("yyyy-MM-dd'T'HH:mm:ss") } var timelineFile: File try: timelineFile = open(timelineLocation, fmWrite) timelineFile.write($timeline.pretty) finally: close(timelineFile) 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 try: (tempFile, tempFileName) = mkstemp("timestamp-mark-", ".txt", "", fmWrite) tempFile.writeLine( """# Edit the time, mark, tags, and notes below. Any lines starting with '#' will # be ignored. When done, save the file and close the editor.""") tempFile.writeLine(mark.time.format(ISO_TIME_FORMAT)) tempFile.writeLine(mark.summary) tempFile.writeLine(mark.tags.join(",")) tempFile.writeLine( """# Everything from the line below to the end of the file will be considered # notes for this timeline mark.""") close(tempFile) discard os.execShellCmd "$EDITOR " & tempFileName & " /dev/tty" var markPart = Time 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 elif markPart == Tags: mark.tags = line.split({',', ';'}); markPart = Notes else: mark.notes &= line & "\x0D\x0A" 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.."]: let idx = marks.findById($args[""]) if idx > 0: result = result.filterIt(it >= idx) if args[""]: let idx = marks.findById($args[""]) 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 = """ Usage: ptk init [options] ptk add [options] ptk add [options] ptk resume [options] [] ptk amend [options] [] [] ptk merge [...] ptk stop [options] ptk continue ptk delete ptk (list | ls) [options] ptk sum-time --ids ... ptk sum-time [options] [] [] ptk (-V | --version) ptk (-h | --help) Options: -E --echo-args Echo the program's understanding of it's arguments. -V --version Print the tool's version information. -a --after Restrict the selection to marks after . -b --before Restrict the selection to marks after . -c --config Use as configuration for the CLI. -e --edit Open the mark in an editor. -f --file Use the given timeline file. -g --tags Add the given tags (comma-separated) to the selected marks. -G --remove-tags Remove the given tag from the selected marks. -h --help Print this usage information. -m --matching Restric the selection to marks matching . -n --notes For add and amend, set the notes for a time mark. -t --time