pit/src/pitpkg/private/libpit.nim

318 lines
10 KiB
Nim

import cliutils, docopt, json, logging, langutils, options, os,
sequtils, strutils, tables, times, timeutils, uuids
from nre import find, match, re, Regex
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
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"
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
## 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 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"
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))
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)
# 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)
## 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 "pit: 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 "pit: 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