## Personal Time Keeper ## ==================== ## ## Simple time keeping CLI import algorithm, docopt, json, langutils, logging, os, sequtils, strutils, tempfile, terminal, times, timeutils, uuids import ptkutil type Mark* = tuple[id: UUID, time: TimeInfo, summary: string, notes: string] Timeline* = tuple[name: string, marks: seq[Mark]] const STOP_MSG = "STOP" let NO_MARK: Mark = ( id: parseUUID("00000000-0000-0000-0000-000000000000"), time: getLocalTime(getTime()), summary: "", notes: "") const ISO_TIME_FORMAT = "yyyy:MM:dd'T'HH:mm:ss" const TIME_FORMATS = @[ "HH:mm", "HH:mm:ss", "HH:mm:ss", "yyyy:MM:dd'T'HH:mm:ss", "yyyy:MM:dd'T'HH:mm"] #proc `$`*(mark: Mark): string = #return (($mark.uuid)[ proc exitErr(msg: string): void = fatal "ptk: " & msg quit(QuitFailure) proc parseTime(timeStr: string): TimeInfo = 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 } template `%`(timeline: Timeline): JsonNode = %* { "name": timeline.name, "marks": timeline.marks } proc loadTimeline(filename: string): Timeline = 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"]: timeline.marks.add(( id: parseUUID(markJson["id"].getStr()), time: parse(markJson["time"].getStr(), ISO_TIME_FORMAT), summary: markJson["summary"].getStr(), notes: markJson["notes"].getStr())) return timeline proc saveTimeline(timeline: Timeline, location: string): void = 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 = 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) proc writeMarks(marks: seq[Mark], includeNotes = false): void = let now = getLocalTime(getTime()) 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" 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 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) if prefix.len > longestPrefix: longestPrefix = prefix.len for i in 0..<marks.len: let mark = marks[i] if mark.summary == STOP_MSG: continue let duration = intervals[i].flexFormat setForegroundColor(stdout, fgBlack, true) write(stdout, ($mark.id)[0..<8]) setForegroundColor(stdout, fgYellow) write(stdout, " " & mark.time.format(timeFormat)) setForegroundColor(stdout, fgCyan) write(stdout, " (" & duration & ")") resetAttributes(stdout) writeLine(stdout, spaces(longestPrefix - prefixLens[i]) & " -- " & mark.summary) if includeNotes and len(mark.notes.strip) > 0: writeLine(stdout, spaces(longestPrefix) & mark.notes) writeLine(stdout, "") proc formatMark(mark: Mark, nextMark = NO_MARK, timeFormat = ISO_TIME_FORMAT, includeNotes = false): string = 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 doInit(timelineLocation: string): void = 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) proc edit(mark: var Mark): void = var tempFile: File tempFileName: string try: (tempFile, tempFileName) = mkstemp("timestamp-mark-", ".txt", "", fmWrite) tempFile.writeLine( """# Edit the time, mark, 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( """# 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 >/dev/tty" var readTime = false readSummary = false for line in lines tempFileName: if strip(line)[0] == '#': continue elif not readTime: mark.time = parseTime(line); readTime = true elif not readSummary: mark.summary = line; readSummary = true else: mark.notes &= line finally: close(tempFile) when isMainModule: try: let doc = """ Usage: ptk init [options] ptk add [options] ptk add [options] <summary> ptk ammend [options] <id> [<summary>] ptk stop [options] ptk continue ptk delete <id> ptk list [options] ptk sum-time --ids <ids>... ptk sum-time [options] [<firstId>] [<lastId>] ptk (-V | --version) ptk (-h | --help) Options: -f --file <file> Use the given timeline file. -c --config <cfgFile> Use <cfgFile> as configuration for the CLI. -t --time <time> For add and ammend, use this time instead of the current time. -n --notes <notes> For add and ammend, set the notes for a time mark. -V --version Print the tool's version information. -e --edit Open the mark in an editor. -a --after <after> Restrict the selection to marks after <after>. -b --before <before> Restrict the selection to marks after <before>. -h --help Print this usage information. -v --verbose Include notes in timeline entry output. -E --echo-args Echo the program's understanding of it's arguments. """ # TODO: add ptk delete [options] logging.addHandler(newConsoleLogger()) # Parse arguments let args = docopt(doc, version = "ptk 0.1.0") if args["--echo-args"]: echo $args if args["--help"]: echo doc quit() # Find and parse the .ptkrc file let ptkrcLocations = @[ if args["--config"]: $args["<cfgFile>"] else:"", ".ptkrc", $getEnv("PTKRC"), $getEnv("HOME") & "/.ptkrc"] var ptkrcFilename: string = foldl(ptkrcLocations, if len(a) > 0: a elif existsFile(b): b else: "") var cfg: JsonNode var cfgFile: File if not existsFile(ptkrcFilename): warn "ptk: could not find .ptkrc file." ptkrcFilename = $getEnv("HOME") & "/.ptkrc" try: cfgFile = open(ptkrcFilename, fmWrite) cfgFile.write("{\"timelineLogFile\": \"timeline.log.json\"}") except: warn "ptk: could not write default .ptkrc to " & ptkrcFilename finally: close(cfgFile) try: cfg = parseFile(ptkrcFilename) except: raise newException(IOError, "unable to read config file: " & ptkrcFilename & "\x0D\x0A" & getCurrentExceptionMsg()) # Find the time log file let timelineLocations = @[ if args["--file"]: $args["<file>"] else: "", $getEnv("PTK_FILE"), cfg["timelineLogFile"].getStr(""), "ptk.log.json"] var timelineLocation = foldl(timelineLocations, if len(a) > 0: a elif existsFile(b): b else: "") # Execute commands if args["init"]: doInit(foldl(timelineLocations, if len(a) > 0: a else: b)) else: if not fileExists(timelineLocation): raise newException(IOError, "time log file doesn't exist: " & timelineLocation) var timeline = loadTimeline(timelineLocation) if args["stop"]: let newMark = ( id: genUUID(), time: if args["--time"]: parseTime($args["--time"]) else: getLocalTime(getTime()), summary: STOP_MSG, notes: args["--notes"] ?: "") timeline.marks.add(newMark) writeMarks( marks = timeline.marks[timeline.marks.len - 2..<timeline.marks.len], includeNotes = args["--verbose"]) echo "stopped timer" saveTimeline(timeline, timelineLocation) if args["continue"]: if timeline.marks.last.summary != STOP_MSG: echo "There is already something in progress:" writeMarks( marks = @[timeline.marks.last], 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()), summary: prevMark.summary, notes: prevMark.notes) timeline.marks.add(newMark) writeMarks(marks = @[newMark], includeNotes = args["--verbose"]) saveTimeline(timeline, timelineLocation) if args["add"]: var newMark: Mark = ( id: genUUID(), time: if args["--time"]: parseTime($args["--time"]) else: getLocalTime(getTime()), summary: args["<summary>"] ?: "", notes: args["--notes"] ?: "") if args["--edit"]: edit(newMark) timeline.marks.add(newMark) writeMarks(marks = @[newMark], includeNotes = args["--verbose"]) saveTimeline(timeline, timelineLocation) if args["ammend"]: # Note, this returns a copy, not a reference to the mark in the seq. let markIdx = timeline.marks.findById($args["<id>"]) var mark = timeline.marks[markIdx] if args["<summary>"]: mark.summary = $args["<summary>"] if args["--notes"]: mark.notes = $args["<notes>"] if args["--time"]: try: mark.time = parseTime($args["--time"]) except: raise newException(ValueError, "invalid value for --time: " & getCurrentExceptionMsg()) if args["--edit"]: edit(mark) echo formatMark( mark = mark, timeFormat = "HH:mm", includeNotes = args["--verbose"]) timeline.marks.delete(markIdx) timeline.marks.insert(mark, markIdx) saveTimeline(timeline, timelineLocation) if args["delete"]: let markIdx = timeline.marks.findById($args["<id>"]) timeline.marks.delete(markIdx) saveTimeline(timeline, timelineLocation) if args["list"]: var marks = timeline.marks 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) 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"]: var intervals: seq[TimeInterval] = @[] if args["--ids"]: for id in args["<ids>"]: let markIdx = timeline.marks.findById(id) 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) else: intervals.add(timeline.marks[markIdx + 1].time - timeline.marks[markIdx].time) else: var startIdx = 0 var endIdx = timeline.marks.len - 1 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) if intervals.len == 0: echo "ptk: no marks found" else: let total = foldl(intervals, a + b) echo total.flexFormat except: fatal "ptk: " & getCurrentExceptionMsg() quit(QuitFailure)