Initial WIP of new pit (Nim).
This commit is contained in:
parent
d6880d9cc1
commit
34e01119a9
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,4 +1,2 @@
|
||||
release/
|
||||
*.sw*
|
||||
*/build/
|
||||
pit-cli/lib/libpit-*.jar
|
||||
nimcache/
|
||||
|
12
pit.nimble
Normal file
12
pit.nimble
Normal file
@ -0,0 +1,12 @@
|
||||
# Package
|
||||
|
||||
version = "4.0.0"
|
||||
author = "Jonathan Bernard"
|
||||
description = "Personal issue tracker."
|
||||
license = "MIT"
|
||||
srcDir = "src"
|
||||
bin = @["pit"]
|
||||
|
||||
# Dependencies
|
||||
|
||||
requires @["nim >= 0.18.1", "uuids 0.1.9", "docopt 0.6.5", "cliutils 0.3.2"]
|
203
src/pit.nim
Normal file
203
src/pit.nim
Normal file
@ -0,0 +1,203 @@
|
||||
## Personal Issue Tracker
|
||||
## ======================
|
||||
##
|
||||
|
||||
import cliutils, docopt, json, logging, os, ospaths, sequtils, strutils,
|
||||
tables, times, uuids
|
||||
|
||||
import pit/private/libpit
|
||||
export libpit
|
||||
|
||||
type
|
||||
CliContext = ref object
|
||||
tasksDir*: string
|
||||
contexts*: TableRef[string, string]
|
||||
issues*: TableRef[IssueState, seq[Issue]]
|
||||
cfg*: CombinedConfig
|
||||
|
||||
proc initContext(args: Table[string, Value]): CliContext =
|
||||
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 existsFile(b): b else: "")
|
||||
|
||||
if not existsFile(pitrcFilename):
|
||||
warn "pit: could not find .pitrc file: " & pitrcFilename
|
||||
if isNilOrWhitespace(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 = CliContext(
|
||||
cfg: cfg,
|
||||
tasksDir: cfg.getVal("tasks-dir", ""),
|
||||
contexts: newTable[string,string](),
|
||||
issues: newTable[IssueState, seq[Issue]]())
|
||||
|
||||
if cfgJson.hasKey("contexts"):
|
||||
for k, v in cfgJson["contexts"]:
|
||||
result.contexts[k] = v.getStr()
|
||||
|
||||
if isNilOrWhitespace(result.tasksDir):
|
||||
raise newException(Exception, "no tasks directory configured")
|
||||
|
||||
if not existsDir(result.tasksDir):
|
||||
raise newException(Exception, "cannot find tasks dir: " & result.tasksDir)
|
||||
|
||||
proc getIssueContextDisplayName(ctx: CliContext, context: string): string =
|
||||
if not ctx.contexts.hasKey(context): return context.capitalize()
|
||||
return ctx.contexts[context]
|
||||
|
||||
proc formatIssue(ctx: CliContext, issue: Issue, state: IssueState,
|
||||
width: int, indent: string, topPadded: bool): string =
|
||||
var showDetails = not issue.details.isNilOrWhitespace
|
||||
var lines: seq[string] = @[]
|
||||
|
||||
if showDetails and not topPadded: lines.add("")
|
||||
|
||||
var wrappedSummary = issue.summary.wordWrap(width - 2).indent(2)
|
||||
wrappedSummary = "*" & wrappedSummary[1..^1]
|
||||
lines.add(wrappedSummary.indent(indent.len))
|
||||
|
||||
if state == Pending and issue.properties.hasKey("pending"):
|
||||
let startIdx = "Pending: ".len
|
||||
var pendingText = issue["pending"].wordWrap(width - startIdx - 2).indent(startIdx)
|
||||
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2)
|
||||
lines.add(pendingText)
|
||||
|
||||
if showDetails: lines.add(issue.details.indent(indent.len + 2))
|
||||
|
||||
return lines.join("\n")
|
||||
|
||||
proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
|
||||
width = 80, indent = " "): string =
|
||||
let innerWidth = width - (indent.len * 2)
|
||||
var lines: seq[string] = @[]
|
||||
|
||||
lines.add(indent & ".".repeat(innerWidth))
|
||||
lines.add(state.displayName.center(width))
|
||||
lines.add("")
|
||||
|
||||
var topPadded = true
|
||||
var showDetails = false
|
||||
|
||||
let issuesByContext = issues.groupBy("context")
|
||||
|
||||
if issues.len > 5 and issuesByContext.len > 1:
|
||||
for context, ctxIssues in issuesByContext:
|
||||
lines.add(indent & ctx.getIssueContextDisplayName(context) & ":")
|
||||
lines.add("")
|
||||
|
||||
for i in ctxIssues:
|
||||
lines.add(ctx.formatIssue(i, state, innerWidth - 2, indent & " ", topPadded))
|
||||
topPadded = not i.details.isNilOrWhitespace
|
||||
|
||||
if not topPadded: lines.add("")
|
||||
|
||||
else:
|
||||
for i in issues:
|
||||
lines.add(ctx.formatIssue(i, state, innerWidth, indent, topPadded))
|
||||
topPadded = not i.details.isNilOrWhitespace
|
||||
|
||||
lines.add("")
|
||||
return lines.join("\n")
|
||||
|
||||
proc loadIssues(ctx: CliContext, state: IssueState): seq[Issue] =
|
||||
result = loadIssues(joinPath(ctx.tasksDir, $state))
|
||||
|
||||
proc loadAllIssues(ctx: CliContext) =
|
||||
for state in IssueState:
|
||||
ctx.issues[state] = loadIssues(ctx, state)
|
||||
|
||||
proc sameDay(a, b: DateTime): bool =
|
||||
result = a.year == b.year and a.yearday == b.yearday
|
||||
|
||||
when isMainModule:
|
||||
|
||||
try:
|
||||
let doc = """
|
||||
Usage:
|
||||
pit new <state> <summary> [options]
|
||||
pit list [<state>] [options]
|
||||
pit today
|
||||
pit start
|
||||
pit done
|
||||
pit pending
|
||||
pit edit
|
||||
|
||||
Options:
|
||||
|
||||
-t, --tags <tags> Specify tags for an issue.
|
||||
-p, --properties <props> Specify properties for an issue. Formatted as "key:val;key:val"
|
||||
-C, --config <cfgFile> Location of the config file (defaults to $HOME/.pitrc)
|
||||
-h, --help Print this usage information.
|
||||
-T, --today Limit to today's issues.
|
||||
-E, --echo-args Echo arguments (for debug purposes).
|
||||
--tasks-dir Path to the tasks directory (defaults to the value
|
||||
configured in the .pitrc file)
|
||||
"""
|
||||
|
||||
logging.addHandler(newConsoleLogger())
|
||||
|
||||
# Parse arguments
|
||||
let args = docopt(doc, version = "ptk 0.12.1")
|
||||
|
||||
if args["--echo-args"]: echo $args
|
||||
|
||||
if args["--help"]:
|
||||
echo doc
|
||||
quit()
|
||||
|
||||
let now = getTime().local
|
||||
|
||||
let ctx = initContext(args)
|
||||
|
||||
## Actual command runners
|
||||
if args["list"]:
|
||||
|
||||
ctx.loadAllIssues()
|
||||
|
||||
let fullWidth = 80
|
||||
let innerWidth = fullWidth - 4
|
||||
|
||||
# Today's items
|
||||
echo '_'.repeat(fullWidth)
|
||||
echo "Today".center(fullWidth)
|
||||
echo '~'.repeat(fullWidth)
|
||||
echo ""
|
||||
|
||||
for s in [Current, TodoToday]:
|
||||
echo ctx.formatSection(ctx.issues[s], s)
|
||||
|
||||
echo ctx.formatSection(
|
||||
ctx.issues[Done].filterIt(
|
||||
it.properties.hasKey("completed") and
|
||||
sameDay(now, it.getDateTime("completed"))), Done)
|
||||
|
||||
# Future items
|
||||
echo '_'.repeat(fullWidth)
|
||||
echo "Future".center(fullWidth)
|
||||
echo '~'.repeat(fullWidth)
|
||||
echo ""
|
||||
|
||||
for s in [Pending, Todo]:
|
||||
echo ctx.formatSection(ctx.issues[s], s)
|
||||
|
||||
except:
|
||||
fatal "pit: " & getCurrentExceptionMsg()
|
||||
#raise getCurrentException()
|
||||
quit(QuitFailure)
|
120
src/pit/private/libpit.nim
Normal file
120
src/pit/private/libpit.nim
Normal file
@ -0,0 +1,120 @@
|
||||
import cliutils, options, os, ospaths, sequtils, strutils, tables, times, uuids
|
||||
|
||||
from nre import re, match
|
||||
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"
|
||||
|
||||
const ISO8601Format* = "yyyy:MM:dd'T'HH:mm:sszzz"
|
||||
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 Pending: result = "Pending"
|
||||
of Done: result = "Done"
|
||||
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
|
||||
|
||||
proc getDateTime*(issue: Issue, key: string): DateTime =
|
||||
return parse(issue.properties[key], ISO8601Format)
|
||||
|
||||
proc setDateTime*(issue: Issue, key: string, dt: DateTime) =
|
||||
issue.properties[key] = format(dt, ISO8601Format)
|
||||
|
||||
## 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.isNilOrWhitespace: 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): string =
|
||||
var lines = @[issue.summary]
|
||||
for key, val in issue.properties: lines.add(key & ": " & val)
|
||||
if issue.tags.len > 0: lines.add("tags: " & issue.tags.join(","))
|
||||
if not isNilOrWhitespace(issue.details):
|
||||
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 storeIssue*(dirPath: string, issue: Issue) =
|
||||
issue.filepath = joinPath(dirPath, $issue.id & ".txt")
|
||||
writeFile(issue.filepath, toStorageFormat(issue))
|
||||
|
||||
proc loadIssues*(path: string): seq[Issue] =
|
||||
result = @[]
|
||||
for path in walkDirRec(path):
|
||||
if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome():
|
||||
result.add(loadIssue(path))
|
||||
|
||||
## Utilities for working with issue collections.
|
||||
proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] =
|
||||
result = newTable[string, seq[Issue]]()
|
||||
for i in issues:
|
||||
let key = if i.properties.hasKey(propertyKey): i[propertyKey] else: ""
|
||||
if not result.hasKey(key): result[key] = newSeq[Issue]()
|
||||
result[key].add(i)
|
||||
|
||||
|
1
tests/test1.nim
Normal file
1
tests/test1.nim
Normal file
@ -0,0 +1 @@
|
||||
doAssert(1 + 1 == 2)
|
1
tests/test1.nims
Normal file
1
tests/test1.nims
Normal file
@ -0,0 +1 @@
|
||||
switch("path", "$projectDir/../src")
|
Loading…
x
Reference in New Issue
Block a user