414 lines
13 KiB
Nim
414 lines
13 KiB
Nim
import cliutils, docopt, json, logging, langutils, options, os,
|
|
sequtils, strformat, strutils, tables, times, timeutils, uuids
|
|
|
|
import nre except toSeq
|
|
|
|
type
|
|
Issue* = ref object
|
|
id*: UUID
|
|
filepath*: string
|
|
summary*, details*: string
|
|
properties*: TableRef[string, string]
|
|
tags*: seq[string]
|
|
|
|
IssueState* = enum
|
|
Current = "current",
|
|
TodoToday = "todo-today",
|
|
Pending = "pending",
|
|
Done = "done",
|
|
Todo = "todo"
|
|
Dormant = "dormant"
|
|
|
|
IssueFilter* = ref object
|
|
completedRange*: Option[tuple[b, e: DateTime]]
|
|
fullMatch*, summaryMatch*: Option[Regex]
|
|
hasTags*: seq[string]
|
|
properties*: TableRef[string, string]
|
|
|
|
PitConfig* = ref object
|
|
tasksDir*: string
|
|
contexts*: TableRef[string, string]
|
|
cfg*: CombinedConfig
|
|
|
|
Recurrence* = object
|
|
cloneId*: Option[string]
|
|
interval*: TimeInterval
|
|
isFromCompletion*: bool
|
|
|
|
const DONE_FOLDER_FORMAT* = "yyyy-MM"
|
|
|
|
let ISSUE_FILE_PATTERN = re"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}\.txt"
|
|
let RECURRENCE_PATTERN = re"(every|after) ((\d+) )?((hour|day|week|month|year)s?)(, ([0-9a-fA-F]+))?"
|
|
|
|
let traceStartTime = cpuTime()
|
|
var lastTraceTime = traceStartTime
|
|
|
|
proc trace*(msg: string, diffFromLast = false) =
|
|
let curTraceTime = cpuTime()
|
|
|
|
if diffFromLast:
|
|
debug &"{(curTraceTime - lastTraceTime) * 1000:6.2f}ms {msg}"
|
|
else:
|
|
debug &"{cpuTime() - traceStartTime:08.4f} {msg}"
|
|
|
|
lastTraceTime = curTraceTime
|
|
|
|
proc displayName*(s: IssueState): string =
|
|
case s
|
|
of Current: result = "Current"
|
|
of Done: result = "Done"
|
|
of Dormant: result = "Dormant"
|
|
of Pending: result = "Pending"
|
|
of Todo: result = "Todo"
|
|
of TodoToday: result = "Todo"
|
|
|
|
## Allow issue properties to be accessed as if the issue was a table
|
|
proc `[]`*(issue: Issue, key: string): string =
|
|
return issue.properties[key]
|
|
|
|
proc `[]=`*(issue: Issue, key: string, value: string) =
|
|
issue.properties[key] = value
|
|
|
|
## Issue property accessors
|
|
proc hasProp*(issue: Issue, key: string): bool =
|
|
return issue.properties.hasKey(key)
|
|
|
|
proc getDateTime*(issue: Issue, key: string): DateTime =
|
|
return issue.properties[key].parseIso8601
|
|
|
|
proc getDateTime*(issue: Issue, key: string, default: DateTime): DateTime =
|
|
if issue.properties.hasKey(key): return issue.properties[key].parseIso8601
|
|
else: return default
|
|
|
|
proc setDateTime*(issue: Issue, key: string, dt: DateTime) =
|
|
issue.properties[key] = dt.formatIso8601
|
|
|
|
proc getRecurrence*(issue: Issue): Option[Recurrence] =
|
|
if not issue.hasProp("recurrence"): return none[Recurrence]()
|
|
|
|
let m = issue["recurrence"].match(RECURRENCE_PATTERN)
|
|
|
|
if not m.isSome:
|
|
warn "not a valid recurrence value: '" & issue["recurrence"] & "'"
|
|
return none[Recurrence]()
|
|
|
|
let c = nre.toSeq(m.get.captures)
|
|
let timeVal = if c[2].isSome: c[2].get.parseInt
|
|
else: 1
|
|
return some(Recurrence(
|
|
isFromCompletion: c[0].get == "after",
|
|
interval:
|
|
case c[4].get:
|
|
of "hour": hours(timeVal)
|
|
of "day": days(timeVal)
|
|
of "week": weeks(timeVal)
|
|
of "month": months(timeVal)
|
|
of "year": years(timeVal)
|
|
else: weeks(1),
|
|
cloneId: c[6]))
|
|
|
|
## Issue filtering
|
|
proc initFilter*(): IssueFilter =
|
|
result = IssueFilter(
|
|
completedRange: none(tuple[b, e: DateTime]),
|
|
fullMatch: none(Regex),
|
|
summaryMatch: none(Regex),
|
|
hasTags: @[],
|
|
properties: newTable[string, string]())
|
|
|
|
proc propsFilter*(props: TableRef[string, string]): IssueFilter =
|
|
if isNil(props):
|
|
raise newException(ValueError,
|
|
"cannot initialize property filter without properties")
|
|
|
|
result = initFilter()
|
|
result.properties = props
|
|
|
|
proc dateFilter*(range: tuple[b, e: DateTime]): IssueFilter =
|
|
result = initFilter()
|
|
result.completedRange = some(range)
|
|
|
|
proc summaryMatchFilter*(pattern: string): IssueFilter =
|
|
result = initFilter()
|
|
result.summaryMatch = some(re("(?i)" & pattern))
|
|
|
|
proc fullMatchFilter*(pattern: string): IssueFilter =
|
|
result = initFilter()
|
|
result.fullMatch = some(re("(?i)" & pattern))
|
|
|
|
proc hasTagsFilter*(tags: seq[string]): IssueFilter =
|
|
result = initFilter()
|
|
result.hasTags = tags
|
|
|
|
proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] =
|
|
result = newTable[string, seq[Issue]]()
|
|
for i in issues:
|
|
let key = if i.hasProp(propertyKey): i[propertyKey] else: ""
|
|
if not result.hasKey(key): result[key] = newSeq[Issue]()
|
|
result[key].add(i)
|
|
|
|
|
|
## Parse and format dates
|
|
const DATE_FORMATS = [
|
|
"MM/dd",
|
|
"MM-dd",
|
|
"yyyy-MM-dd",
|
|
"yyyy/MM/dd",
|
|
"yyyy-MM-dd'T'hh:mm:ss"
|
|
]
|
|
proc parseDate*(d: string): DateTime =
|
|
var errMsg = ""
|
|
for df in DATE_FORMATS:
|
|
try: return d.parse(df)
|
|
except:
|
|
errMsg &= "\n\tTried " & df & " with " & d
|
|
continue
|
|
raise newException(ValueError, "Unable to parse input as a date: " & d & errMsg)
|
|
|
|
## Parse and format issues
|
|
proc fromStorageFormat*(id: string, issueTxt: string): Issue =
|
|
type ParseState = enum ReadingSummary, ReadingProps, ReadingDetails
|
|
|
|
result = Issue(
|
|
id: parseUUID(id),
|
|
properties: newTable[string,string](),
|
|
tags: @[])
|
|
|
|
var parseState = ReadingSummary
|
|
var detailLines: seq[string] = @[]
|
|
|
|
for line in issueTxt.splitLines():
|
|
if line.startsWith("#"): continue # ignore lines starting with '#'
|
|
|
|
case parseState
|
|
|
|
of ReadingSummary:
|
|
result.summary = line.strip()
|
|
parseState = ReadingProps
|
|
|
|
of ReadingProps:
|
|
# Ignore empty lines
|
|
if line.isEmptyOrWhitespace: continue
|
|
|
|
# Look for the sentinal to start parsing as detail lines
|
|
if line == "--------":
|
|
parseState = ReadingDetails
|
|
continue
|
|
|
|
|
|
let parts = line.split({':'}, 1).mapIt(it.strip())
|
|
if parts.len != 2:
|
|
raise newException(ValueError, "unable to parse property line: " & line)
|
|
|
|
# Take care of special properties: `tags`
|
|
if parts[0] == "tags": result.tags = parts[1].split({','}).mapIt(it.strip())
|
|
else: result[parts[0]] = parts[1]
|
|
|
|
of ReadingDetails:
|
|
detailLines.add(line)
|
|
|
|
result.details = if detailLines.len > 0: detailLines.join("\n") else: ""
|
|
|
|
proc toStorageFormat*(issue: Issue, withComments = false): string =
|
|
var lines: seq[string] = @[]
|
|
if withComments: lines.add("# Summary (one line):")
|
|
lines.add(issue.summary)
|
|
if withComments: lines.add("# Properties (\"key:value\" per line):")
|
|
for key, val in issue.properties:
|
|
if not val.isEmptyOrWhitespace: lines.add(key & ": " & val)
|
|
if issue.tags.len > 0: lines.add("tags: " & issue.tags.join(","))
|
|
if not isEmptyOrWhitespace(issue.details) or withComments:
|
|
if withComments: lines.add("# Details go below the \"--------\"")
|
|
lines.add("--------")
|
|
lines.add(issue.details)
|
|
|
|
result = lines.join("\n")
|
|
|
|
## Load and store from filesystem
|
|
proc loadIssue*(filePath: string): Issue =
|
|
result = fromStorageFormat(splitFile(filePath).name, readFile(filePath))
|
|
result.filepath = filePath
|
|
|
|
proc loadIssueById*(tasksDir, id: string): Issue =
|
|
for path in walkDirRec(tasksDir):
|
|
if path.splitFile.name.startsWith(id):
|
|
return loadIssue(path)
|
|
raise newException(KeyError, "cannot find issue for id: " & id)
|
|
|
|
proc store*(issue: Issue, withComments = false) =
|
|
discard existsOrCreateDir(issue.filePath.parentDir)
|
|
writeFile(issue.filepath, toStorageFormat(issue, withComments))
|
|
|
|
proc store*(tasksDir: string, issue: Issue, state: IssueState, withComments = false) =
|
|
let stateDir = tasksDir / $state
|
|
let filename = $issue.id & ".txt"
|
|
if state == Done:
|
|
let monthPath = issue.getDateTime("completed", getTime().local).format(DONE_FOLDER_FORMAT)
|
|
issue.filepath = stateDir / monthPath / filename
|
|
else:
|
|
issue.filepath = stateDir / filename
|
|
|
|
issue.store(withComments)
|
|
|
|
proc storeOrder*(issues: seq[Issue], path: string) =
|
|
var orderLines = newSeq[string]()
|
|
|
|
for context, issues in issues.groupBy("context"):
|
|
orderLines.add("> " & context)
|
|
for issue in issues: orderLines.add($issue.id & " " & issue.summary)
|
|
orderLines.add("")
|
|
|
|
let orderFile = path / "order.txt"
|
|
orderFile.writeFile(orderLines.join("\n"))
|
|
|
|
proc loadIssues*(path: string): seq[Issue] =
|
|
let orderFile = path / "order.txt"
|
|
|
|
trace "loading issues under " & path
|
|
|
|
let orderedIds =
|
|
if fileExists(orderFile):
|
|
toSeq(orderFile.lines)
|
|
.mapIt(it.split(' ')[0])
|
|
.deduplicate
|
|
.filterIt(not it.startsWith("> ") and not it.isEmptyOrWhitespace)
|
|
else: newSeq[string]()
|
|
|
|
type TaggedIssue = tuple[issue: Issue, ordered: bool]
|
|
var unorderedIssues: seq[TaggedIssue] = @[]
|
|
|
|
for path in walkDirRec(path):
|
|
if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome():
|
|
unorderedIssues.add((loadIssue(path), false))
|
|
|
|
trace "loaded " & $unorderedIssues.len & " issues", true
|
|
result = @[]
|
|
|
|
# Add all ordered issues in order
|
|
for id in orderedIds:
|
|
let idx = unorderedIssues.indexOf(($it.issue.id).startsWith(id))
|
|
if idx > 0:
|
|
result.add(unorderedIssues[idx].issue)
|
|
unorderedIssues[idx].ordered = true
|
|
|
|
# Add all remaining, unordered issues in the order they were loaded
|
|
for taggedIssue in unorderedIssues:
|
|
if taggedIssue.ordered: continue
|
|
result.add(taggedIssue.issue)
|
|
|
|
trace "ordered the loaded issues", true
|
|
|
|
# Finally, save current order
|
|
result.storeOrder(path)
|
|
|
|
proc changeState*(issue: Issue, tasksDir: string, newState: IssueState) =
|
|
let oldFilepath = issue.filepath
|
|
if newState == Done: issue.setDateTime("completed", getTime().local)
|
|
tasksDir.store(issue, newState)
|
|
if oldFilePath != issue.filepath: removeFile(oldFilepath)
|
|
|
|
proc delete*(issue: Issue) = removeFile(issue.filepath)
|
|
|
|
proc nextRecurrence*(tasksDir: string, rec: Recurrence, defaultIssue: Issue): Issue =
|
|
let baseIssue = if rec.cloneId.isSome: tasksDir.loadIssueById(rec.cloneId.get)
|
|
else: defaultIssue
|
|
|
|
let newProps = newTable[string,string]()
|
|
for k, v in baseIssue.properties:
|
|
if k != "completed": newProps[k] = v
|
|
newProps["prev-recurrence"] = $baseIssue.id
|
|
|
|
result = Issue(
|
|
id: genUUID(),
|
|
summary: baseIssue.summary,
|
|
properties: newProps,
|
|
tags: baseIssue.tags)
|
|
|
|
let now = getTime().local
|
|
|
|
let startDate =
|
|
if rec.isFromCompletion:
|
|
if baseIssue.hasProp("completed"): baseIssue.getDateTime("completed")
|
|
else: now
|
|
else:
|
|
if baseIssue.hasProp("created"): baseIssue.getDateTime("created")
|
|
else: now
|
|
|
|
# walk the calendar until the next recurrence that is after the current time.
|
|
var nextTime = startDate + rec.interval
|
|
while now > nextTime: nextTime += rec.interval
|
|
|
|
result.setDateTime("hide-until", nextTime)
|
|
|
|
## Utilities for working with issue collections.
|
|
proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
|
|
result = issues
|
|
|
|
for k,v in filter.properties:
|
|
result = result.filterIt(it.hasProp(k) and it[k] == v)
|
|
|
|
if filter.completedRange.isSome:
|
|
let range = filter.completedRange.get
|
|
result = result.filterIt(
|
|
not it.hasProp("completed") or
|
|
it.getDateTime("completed").between(range.b, range.e))
|
|
|
|
if filter.summaryMatch.isSome:
|
|
let p = filter.summaryMatch.get
|
|
result = result.filterIt(it.summary.find(p).isSome)
|
|
|
|
if filter.fullMatch.isSome:
|
|
let p = filter.fullMatch.get
|
|
result = result.filterIt( it.summary.find(p).isSome or it.details.find(p).isSome)
|
|
|
|
for tag in filter.hasTags:
|
|
result = result.filterIt(it.tags.find(tag) >= 0)
|
|
|
|
### Configuration utilities
|
|
proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitConfig =
|
|
let pitrcLocations = @[
|
|
if args["--config"]: $args["--config"] else: "",
|
|
".pitrc", $getEnv("PITRC"), $getEnv("HOME") & "/.pitrc"]
|
|
|
|
var pitrcFilename: string =
|
|
foldl(pitrcLocations, if len(a) > 0: a elif fileExists(b): b else: "")
|
|
|
|
if not fileExists(pitrcFilename):
|
|
warn "could not find .pitrc file: " & pitrcFilename
|
|
if isEmptyOrWhitespace(pitrcFilename):
|
|
pitrcFilename = $getEnv("HOME") & "/.pitrc"
|
|
var cfgFile: File
|
|
try:
|
|
cfgFile = open(pitrcFilename, fmWrite)
|
|
cfgFile.write("{\"tasksDir\": \"/path/to/tasks\"}")
|
|
except: warn "could not write default .pitrc to " & pitrcFilename
|
|
finally: close(cfgFile)
|
|
|
|
var cfgJson: JsonNode
|
|
try: cfgJson = parseFile(pitrcFilename)
|
|
except: raise newException(IOError,
|
|
"unable to read config file: " & pitrcFilename &
|
|
"\x0D\x0A" & getCurrentExceptionMsg())
|
|
|
|
let cfg = CombinedConfig(docopt: args, json: cfgJson)
|
|
|
|
result = PitConfig(
|
|
cfg: cfg,
|
|
contexts: newTable[string,string](),
|
|
tasksDir: cfg.getVal("tasks-dir", ""))
|
|
|
|
if cfgJson.hasKey("contexts"):
|
|
for k, v in cfgJson["contexts"]:
|
|
result.contexts[k] = v.getStr()
|
|
|
|
if isEmptyOrWhitespace(result.tasksDir):
|
|
raise newException(Exception, "no tasks directory configured")
|
|
|
|
if not dirExists(result.tasksDir):
|
|
raise newException(Exception, "cannot find tasks dir: " & result.tasksDir)
|
|
|
|
# Create our tasks directory structure if needed
|
|
for s in IssueState:
|
|
if not dirExists(result.tasksDir / $s):
|
|
(result.tasksDir / $s).createDir
|