149 lines
5.4 KiB
Nim
149 lines
5.4 KiB
Nim
import algorithm, json, sequtils, strutils, times, timeutils, uuids
|
|
|
|
import ./util
|
|
|
|
type
|
|
Mark* = tuple[id: UUID, time: DateTime, 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.
|
|
|
|
OffsetFrom = enum Year, Month, Day, None
|
|
|
|
const STOP_MSG* = "STOP"
|
|
|
|
let NO_MARK*: Mark = (
|
|
id: parseUUID("00000000-0000-0000-0000-000000000000"),
|
|
time: fromUnix(0).local,
|
|
summary: "", notes: "", tags: @[])
|
|
|
|
const ISO_TIME_FORMAT* = "yyyy-MM-dd'T'HH:mm:ss"
|
|
## The canonical time format used by PTK.
|
|
|
|
const TIME_FORMATS* = @[
|
|
(fmtStr: "yyyy-MM-dd'T'HH:mm:sszzz", offsetFrom: OffsetFrom.None),
|
|
(fmtStr: "yyyy-MM-dd HH:mm:sszzz", offsetFrom: OffsetFrom.None),
|
|
(fmtStr: "yyyy-MM-dd'T'HH:mm:sszz", offsetFrom: OffsetFrom.None),
|
|
(fmtStr: "yyyy-MM-dd HH:mm:sszz", offsetFrom: OffsetFrom.None),
|
|
(fmtStr: "yyyy-MM-dd'T'HH:mm:ssz", offsetFrom: OffsetFrom.None),
|
|
(fmtStr: "yyyy-MM-dd HH:mm:ssz", offsetFrom: OffsetFrom.None),
|
|
(fmtStr: "yyyy-MM-dd'T'HH:mm:ss", offsetFrom: OffsetFrom.None),
|
|
(fmtStr: "yyyy-MM-dd HH:mm:ss", offsetFrom: OffsetFrom.None),
|
|
(fmtStr: "yyyy-MM-dd'T'HH:mm", offsetFrom: OffsetFrom.None),
|
|
(fmtStr: "yyyy-MM-dd HH:mm", offsetFrom: OffsetFrom.None),
|
|
(fmtStr: "yyyy-MM-dd", offsetFrom: OffsetFrom.None),
|
|
(fmtStr: "yyyy-MM", offsetFrom: OffsetFrom.None),
|
|
(fmtStr: "MM-dd'T'HH:mm:ss", offsetFrom: OffsetFrom.Year),
|
|
(fmtStr: "MM-dd HH:mm:ss", offsetFrom: OffsetFrom.Year),
|
|
(fmtStr: "MM-dd'T'HH:mm", offsetFrom: OffsetFrom.Year),
|
|
(fmtStr: "MM-dd HH:mm", offsetFrom: OffsetFrom.Year),
|
|
(fmtStr: "HH:mm:ss", offsetFrom: OffsetFrom.Day),
|
|
(fmtStr: "H:mm:ss", offsetFrom: OffsetFrom.Day),
|
|
(fmtStr: "H:mm", offsetFrom: OffsetFrom.Day),
|
|
(fmtStr: "HH:mm", offsetFrom: OffsetFrom.Day) ]
|
|
## Other time formats that PTK will accept as input.
|
|
|
|
proc getOrFail*(n: JsonNode, key: string, objName: string = ""): JsonNode =
|
|
## convenience method to get a key from a JObject or raise an exception
|
|
if not n.hasKey(key): raiseEx objName & " missing key '" & key & "'"
|
|
return n[key]
|
|
|
|
proc getIfExists*(n: JsonNode, key: string): JsonNode =
|
|
## convenience method to get a key from a JObject or return null
|
|
result = if n.hasKey(key): n[key]
|
|
else: newJNull()
|
|
|
|
proc parseTime*(timeStr: string): DateTime =
|
|
## Helper to parse time strings trying multiple known formats.
|
|
let now = now()
|
|
|
|
for fmt in TIME_FORMATS:
|
|
try:
|
|
let parsed = parse(timeStr, fmt.fmtStr)
|
|
case fmt.offsetFrom:
|
|
of OffsetFrom.None:
|
|
return parsed
|
|
of OffsetFrom.Year:
|
|
return dateTime(now.year, parsed.month, parsed.monthday,
|
|
parsed.hour, parsed.minute, parsed.second, parsed.nanosecond,
|
|
now.timezone)
|
|
of OffsetFrom.Month:
|
|
return initDateTime(parsed.monthday, now.month, now.year,
|
|
parsed.hour, parsed.minute, parsed.second, parsed.nanosecond,
|
|
now.timezone)
|
|
of OffsetFrom.Day:
|
|
return initDateTime(now.monthday, now.month, now.year, parsed.hour,
|
|
parsed.minute, parsed.second, parsed.nanosecond, now.timezone)
|
|
except: discard nil
|
|
|
|
raise newException(Exception, "unable to interpret as a date: " & timeStr)
|
|
|
|
proc parseMark*(json: JsonNode): Mark =
|
|
|
|
return (
|
|
id: parseUUID(json["id"].getStr()),
|
|
time: parse(json["time"].getStr(), ISO_TIME_FORMAT),
|
|
summary: json["summary"].getStr(),
|
|
notes: json["notes"].getStr(),
|
|
tags: json["tags"].getElems(@[]).map(proc (t: JsonNode): string = t.getStr()))
|
|
|
|
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"]: timeline.marks.add(parseMark(markJson))
|
|
|
|
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 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
|