Compare commits
3 Commits
Author | SHA1 | Date | |
---|---|---|---|
099848edca | |||
d3fc1cdf9c | |||
237f5026f2 |
124
ptk.nim
124
ptk.nim
@ -4,13 +4,16 @@
|
||||
## Simple time keeping CLI
|
||||
|
||||
import algorithm, docopt, json, langutils, logging, os, nre, sequtils,
|
||||
strutils, tempfile, terminal, times, timeutils, uuids
|
||||
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"
|
||||
|
||||
@ -19,12 +22,16 @@ let NO_MARK: Mark = (
|
||||
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 HH:mm:ss", "yyyy:MM:dd 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)[
|
||||
@ -34,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
|
||||
@ -53,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:
|
||||
@ -62,9 +74,17 @@ 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())))
|
||||
@ -75,6 +95,8 @@ proc loadTimeline(filename: string): Timeline =
|
||||
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)
|
||||
@ -83,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'"
|
||||
@ -94,6 +118,8 @@ proc flexFormat(i: TimeInterval): string =
|
||||
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())
|
||||
|
||||
@ -152,6 +178,9 @@ proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): vo
|
||||
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())
|
||||
@ -178,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()
|
||||
@ -197,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
|
||||
@ -231,6 +273,9 @@ 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)
|
||||
|
||||
@ -295,7 +340,9 @@ 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>
|
||||
@ -332,7 +379,7 @@ Options:
|
||||
let now = getLocalTime(getTime())
|
||||
|
||||
# Parse arguments
|
||||
let args = docopt(doc, version = "ptk 0.5.0")
|
||||
let args = docopt(doc, version = "ptk 0.8.0")
|
||||
|
||||
if args["--echo-args"]: echo $args
|
||||
|
||||
@ -378,6 +425,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):
|
||||
@ -439,17 +510,50 @@ Options:
|
||||
|
||||
if args["--edit"]: edit(newMark)
|
||||
|
||||
let prevLastIdx = timeline.marks.getLastIndex()
|
||||
timeline.marks.add(newMark)
|
||||
timeline.writeMarks(
|
||||
indices = @[timeline.marks.len - 1],
|
||||
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>"]
|
||||
|
@ -1,6 +1,6 @@
|
||||
# Package
|
||||
|
||||
version = "0.5.0"
|
||||
version = "0.8.0"
|
||||
author = "Jonathan Bernard"
|
||||
description = "Personal Time Keeper"
|
||||
license = "MIT"
|
||||
|
Reference in New Issue
Block a user