Implemented new, edit, state transitions.
This commit is contained in:
parent
d86da67284
commit
11b18317bd
@ -9,4 +9,4 @@ bin = @["pit"]
|
|||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
|
|
||||||
requires @["nim >= 0.18.1", "uuids 0.1.9", "docopt 0.6.5", "cliutils 0.3.3"]
|
requires @["nim >= 0.18.0", "uuids 0.1.9", "docopt 0.6.5", "cliutils 0.3.4", "timeutils 0.3.0"]
|
||||||
|
280
src/pit.nim
280
src/pit.nim
@ -1,20 +1,21 @@
|
|||||||
## Personal Issue Tracker
|
## Personal Issue Tracker
|
||||||
## ======================
|
## ======================
|
||||||
##
|
##
|
||||||
|
|
||||||
import cliutils, docopt, json, logging, os, ospaths, sequtils, strutils,
|
import cliutils, docopt, json, logging, options, os, ospaths, sequtils,
|
||||||
tables, times, unicode, uuids
|
tables, terminal, times, unicode, uuids
|
||||||
|
|
||||||
|
import strutils except capitalize
|
||||||
import pit/private/libpit
|
import pit/private/libpit
|
||||||
export libpit
|
export libpit
|
||||||
|
|
||||||
type
|
type
|
||||||
CliContext = ref object
|
CliContext = ref object
|
||||||
|
autoList, triggerPtk: bool
|
||||||
tasksDir*: string
|
tasksDir*: string
|
||||||
contexts*: TableRef[string, string]
|
contexts*: TableRef[string, string]
|
||||||
issues*: TableRef[IssueState, seq[Issue]]
|
issues*: TableRef[IssueState, seq[Issue]]
|
||||||
termWidth*: int
|
termWidth*: int
|
||||||
cfg*: CombinedConfig
|
|
||||||
|
|
||||||
proc initContext(args: Table[string, Value]): CliContext =
|
proc initContext(args: Table[string, Value]): CliContext =
|
||||||
let pitrcLocations = @[
|
let pitrcLocations = @[
|
||||||
@ -44,11 +45,12 @@ proc initContext(args: Table[string, Value]): CliContext =
|
|||||||
let cfg = CombinedConfig(docopt: args, json: cfgJson)
|
let cfg = CombinedConfig(docopt: args, json: cfgJson)
|
||||||
|
|
||||||
result = CliContext(
|
result = CliContext(
|
||||||
cfg: cfg,
|
autoList: cfgJson.getOrDefault("autoList").getBool(false),
|
||||||
tasksDir: cfg.getVal("tasks-dir", ""),
|
|
||||||
contexts: newTable[string,string](),
|
contexts: newTable[string,string](),
|
||||||
issues: newTable[IssueState, seq[Issue]](),
|
issues: newTable[IssueState, seq[Issue]](),
|
||||||
termWidth: parseInt(cfg.getVal("term-width", "80")))
|
tasksDir: cfg.getVal("tasks-dir", ""),
|
||||||
|
termWidth: parseInt(cfg.getVal("term-width", "80")),
|
||||||
|
triggerPtk: cfgJson.getOrDefault("triggerPtk").getBool(false))
|
||||||
|
|
||||||
if cfgJson.hasKey("contexts"):
|
if cfgJson.hasKey("contexts"):
|
||||||
for k, v in cfgJson["contexts"]:
|
for k, v in cfgJson["contexts"]:
|
||||||
@ -61,39 +63,54 @@ proc initContext(args: Table[string, Value]): CliContext =
|
|||||||
raise newException(Exception, "cannot find tasks dir: " & result.tasksDir)
|
raise newException(Exception, "cannot find tasks dir: " & result.tasksDir)
|
||||||
|
|
||||||
proc getIssueContextDisplayName(ctx: CliContext, context: string): string =
|
proc getIssueContextDisplayName(ctx: CliContext, context: string): string =
|
||||||
if not ctx.contexts.hasKey(context): return context.capitalize()
|
if not ctx.contexts.hasKey(context):
|
||||||
|
if context.isNilOrWhitespace: return "<default>"
|
||||||
|
else: return context.capitalize()
|
||||||
return ctx.contexts[context]
|
return ctx.contexts[context]
|
||||||
|
|
||||||
proc formatIssue(ctx: CliContext, issue: Issue, state: IssueState,
|
proc writeIssue(ctx: CliContext, issue: Issue, state: IssueState,
|
||||||
width: int, indent: string, topPadded: bool): string =
|
width: int, indent: string, topPadded: bool) =
|
||||||
var showDetails = not issue.details.isNilOrWhitespace
|
var showDetails = not issue.details.isNilOrWhitespace
|
||||||
var lines: seq[string] = @[]
|
|
||||||
|
|
||||||
if showDetails and not topPadded: lines.add("")
|
if showDetails and not topPadded: stdout.writeLine("")
|
||||||
|
|
||||||
var wrappedSummary = issue.summary.wordWrap(width - 2).indent(2)
|
# Wrap and write the summary.
|
||||||
wrappedSummary = "*" & wrappedSummary[1..^1]
|
var wrappedSummary = (" ".repeat(5) & issue.summary).wordWrap(width - 2).indent(2 + indent.len)
|
||||||
lines.add(wrappedSummary.indent(indent.len))
|
wrappedSummary = wrappedSummary[(6 + indent.len)..^1]
|
||||||
|
stdout.setForegroundColor(fgBlack, true)
|
||||||
|
stdout.write(indent & ($issue.id)[0..<6])
|
||||||
|
stdout.setForegroundColor(fgCyan, false)
|
||||||
|
stdout.write(wrappedSummary)
|
||||||
|
|
||||||
if state == Pending and issue.properties.hasKey("pending"):
|
if issue.tags.len > 0:
|
||||||
|
stdout.setForegroundColor(fgGreen, false)
|
||||||
|
let tagsStr = "(" & issue.tags.join(",") & ")"
|
||||||
|
if (wrappedSummary.splitLines[^1].len + tagsStr.len + 1) < (width - 2):
|
||||||
|
stdout.writeLine(" " & tagsStr)
|
||||||
|
else:
|
||||||
|
stdout.writeLine("\n" & indent & " " & tagsStr)
|
||||||
|
else: stdout.writeLine("")
|
||||||
|
stdout.resetAttributes
|
||||||
|
|
||||||
|
if state == Pending and issue.hasProp("pending"):
|
||||||
let startIdx = "Pending: ".len
|
let startIdx = "Pending: ".len
|
||||||
var pendingText = issue["pending"].wordWrap(width - startIdx - 2)
|
var pendingText = issue["pending"].wordWrap(width - startIdx - 2)
|
||||||
.indent(startIdx)
|
.indent(startIdx)
|
||||||
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2)
|
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2)
|
||||||
lines.add(pendingText)
|
stdout.writeLine(pendingText)
|
||||||
|
|
||||||
if showDetails: lines.add(issue.details.indent(indent.len + 2))
|
if showDetails: stdout.writeLine(issue.details.indent(indent.len + 2))
|
||||||
|
|
||||||
return lines.join("\n")
|
|
||||||
|
|
||||||
proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
|
proc writeSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
|
||||||
indent = ""): string =
|
indent = "") =
|
||||||
let innerWidth = ctx.termWidth - (indent.len * 2)
|
let innerWidth = ctx.termWidth - (indent.len * 2)
|
||||||
var lines: seq[string] = @[]
|
|
||||||
|
|
||||||
lines.add(indent & ".".repeat(innerWidth))
|
stdout.setForegroundColor(fgBlue, true)
|
||||||
lines.add(state.displayName.center(ctx.termWidth))
|
stdout.writeLine(indent & ".".repeat(innerWidth))
|
||||||
lines.add("")
|
stdout.writeLine(state.displayName.center(ctx.termWidth))
|
||||||
|
stdout.writeLine("")
|
||||||
|
stdout.resetAttributes
|
||||||
|
|
||||||
var topPadded = true
|
var topPadded = true
|
||||||
|
|
||||||
@ -101,64 +118,140 @@ proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
|
|||||||
|
|
||||||
if issues.len > 5 and issuesByContext.len > 1:
|
if issues.len > 5 and issuesByContext.len > 1:
|
||||||
for context, ctxIssues in issuesByContext:
|
for context, ctxIssues in issuesByContext:
|
||||||
lines.add(indent & ctx.getIssueContextDisplayName(context) & ":")
|
stdout.setForegroundColor(fgYellow, false)
|
||||||
lines.add("")
|
stdout.writeLine(indent & ctx.getIssueContextDisplayName(context) & ":")
|
||||||
|
stdout.writeLine("")
|
||||||
|
stdout.resetAttributes
|
||||||
|
|
||||||
for i in ctxIssues:
|
for i in ctxIssues:
|
||||||
lines.add(ctx.formatIssue(i, state, innerWidth - 2, indent & " ", topPadded))
|
ctx.writeIssue(i, state, innerWidth - 2, indent & " ", topPadded)
|
||||||
topPadded = not i.details.isNilOrWhitespace
|
topPadded = not i.details.isNilOrWhitespace
|
||||||
|
|
||||||
if not topPadded: lines.add("")
|
if not topPadded: stdout.writeLine("")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
for i in issues:
|
for i in issues:
|
||||||
lines.add(ctx.formatIssue(i, state, innerWidth, indent, topPadded))
|
ctx.writeIssue(i, state, innerWidth, indent, topPadded)
|
||||||
topPadded = not i.details.isNilOrWhitespace
|
topPadded = not i.details.isNilOrWhitespace
|
||||||
|
|
||||||
lines.add("")
|
stdout.writeLine("")
|
||||||
return lines.join("\n")
|
|
||||||
|
|
||||||
proc loadIssues(ctx: CliContext, state: IssueState) =
|
proc loadIssues(ctx: CliContext, state: IssueState) =
|
||||||
ctx.issues[state] = loadIssues(joinPath(ctx.tasksDir, $state))
|
ctx.issues[state] = loadIssues(ctx.tasksDir / $state)
|
||||||
|
|
||||||
proc loadAllIssues(ctx: CliContext) =
|
proc loadAllIssues(ctx: CliContext) =
|
||||||
|
ctx.issues = newTable[IssueState, seq[Issue]]()
|
||||||
for state in IssueState: ctx.loadIssues(state)
|
for state in IssueState: ctx.loadIssues(state)
|
||||||
|
|
||||||
|
proc filterIssues(ctx: CliContext, filter: IssueFilter) =
|
||||||
|
for state, issueList in ctx.issues:
|
||||||
|
ctx.issues[state] = issueList.filter(filter)
|
||||||
|
|
||||||
|
proc parsePropertiesOption(propsOpt: string): TableRef[string, string] =
|
||||||
|
result = newTable[string, string]()
|
||||||
|
for propText in propsOpt.split(";"):
|
||||||
|
let pair = propText.split(":", 1)
|
||||||
|
if pair.len == 1: result[pair[0]] = "true"
|
||||||
|
else: result[pair[0]] = pair[1]
|
||||||
|
|
||||||
proc sameDay(a, b: DateTime): bool =
|
proc sameDay(a, b: DateTime): bool =
|
||||||
result = a.year == b.year and a.yearday == b.yearday
|
result = a.year == b.year and a.yearday == b.yearday
|
||||||
|
|
||||||
proc formatHeader(ctx: CliContext, header: string): string =
|
proc writeHeader(ctx: CliContext, header: string) =
|
||||||
var lines: seq[string] = @[]
|
stdout.setForegroundColor(fgRed, true)
|
||||||
lines.add('_'.repeat(ctx.termWidth))
|
stdout.writeLine('_'.repeat(ctx.termWidth))
|
||||||
lines.add(header.center(ctx.termWidth))
|
stdout.writeLine(header.center(ctx.termWidth))
|
||||||
lines.add('~'.repeat(ctx.termWidth))
|
stdout.writeLine('~'.repeat(ctx.termWidth))
|
||||||
lines.add("")
|
stdout.resetAttributes
|
||||||
return lines.join("\n")
|
|
||||||
|
proc edit(issue: Issue) =
|
||||||
|
|
||||||
|
# Write format comments (to help when editing)
|
||||||
|
writeFile(issue.filepath, toStorageFormat(issue, true))
|
||||||
|
|
||||||
|
let editor =
|
||||||
|
if existsEnv("EDITOR"): getEnv("EDITOR")
|
||||||
|
else: "vi"
|
||||||
|
|
||||||
|
discard os.execShellCmd(editor & " " & issue.filepath & " </dev/tty >/dev/tty")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to parse the newly-edited issue to make sure it was successful.
|
||||||
|
let editedIssue = loadIssue(issue.filepath)
|
||||||
|
editedIssue.store()
|
||||||
|
except:
|
||||||
|
fatal "pit: updated issue is invalid (ignoring edits): \n\t" &
|
||||||
|
getCurrentExceptionMsg()
|
||||||
|
issue.store()
|
||||||
|
|
||||||
|
proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState], today, future: bool) =
|
||||||
|
|
||||||
|
if state.isSome:
|
||||||
|
ctx.loadIssues(state.get)
|
||||||
|
if filter.isSome: ctx.filterIssues(filter.get)
|
||||||
|
ctx.writeSection(ctx.issues[state.get], state.get)
|
||||||
|
return
|
||||||
|
|
||||||
|
ctx.loadAllIssues()
|
||||||
|
if filter.isSome: ctx.filterIssues(filter.get)
|
||||||
|
|
||||||
|
let indent = if today and future: " " else: ""
|
||||||
|
|
||||||
|
# Today's items
|
||||||
|
if today:
|
||||||
|
if future: ctx.writeHeader("Today")
|
||||||
|
|
||||||
|
for s in [Current, TodoToday]:
|
||||||
|
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
|
||||||
|
ctx.writeSection(ctx.issues[s], s, indent)
|
||||||
|
|
||||||
|
if ctx.issues.hasKey(Done):
|
||||||
|
let doneIssues = ctx.issues[Done].filterIt(
|
||||||
|
it.hasProp("completed") and
|
||||||
|
sameDay(getTime().local, it.getDateTime("completed")))
|
||||||
|
if doneIssues.len > 0:
|
||||||
|
ctx.writeSection(doneIssues, Done, indent)
|
||||||
|
|
||||||
|
# Future items
|
||||||
|
if future:
|
||||||
|
if today: ctx.writeHeader("Future")
|
||||||
|
|
||||||
|
for s in [Pending, Todo]:
|
||||||
|
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
|
||||||
|
ctx.writeSection(ctx.issues[s], s, indent)
|
||||||
|
|
||||||
when isMainModule:
|
when isMainModule:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
let doc = """
|
let doc = """
|
||||||
Usage:
|
Usage:
|
||||||
pit new <state> <summary> [options]
|
pit new <summary> [<state>] [options]
|
||||||
pit list [<state>] [options]
|
pit list [<state>] [options]
|
||||||
pit today
|
pit ( start | done | pending | do-today | todo ) <id>...
|
||||||
pit start
|
pit edit <id>
|
||||||
pit done
|
|
||||||
pit pending
|
|
||||||
pit edit
|
|
||||||
|
|
||||||
Options:
|
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.
|
-h, --help Print this usage information.
|
||||||
|
|
||||||
|
-t, --tags <tags> Specify tags for an issue.
|
||||||
|
|
||||||
|
-p, --properties <props> Specify properties. Formatted as "key:val;key:val"
|
||||||
|
When used with the list command this option applies
|
||||||
|
a filter to the issues listed, only allowing those
|
||||||
|
which have all of the given properties.
|
||||||
|
|
||||||
-T, --today Limit to today's issues.
|
-T, --today Limit to today's issues.
|
||||||
|
|
||||||
-F, --future Limit to future issues.
|
-F, --future Limit to future issues.
|
||||||
|
|
||||||
|
-C, --config <cfgFile> Location of the config file (defaults to $HOME/.pitrc)
|
||||||
|
|
||||||
-E, --echo-args Echo arguments (for debug purposes).
|
-E, --echo-args Echo arguments (for debug purposes).
|
||||||
|
|
||||||
--tasks-dir Path to the tasks directory (defaults to the value
|
--tasks-dir Path to the tasks directory (defaults to the value
|
||||||
configured in the .pitrc file)
|
configured in the .pitrc file)
|
||||||
|
|
||||||
--term-width Manually set the terminal width to use.
|
--term-width Manually set the terminal width to use.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -167,53 +260,78 @@ Options:
|
|||||||
# Parse arguments
|
# Parse arguments
|
||||||
let args = docopt(doc, version = "ptk 0.12.1")
|
let args = docopt(doc, version = "ptk 0.12.1")
|
||||||
|
|
||||||
if args["--echo-args"]: echo $args
|
if args["--echo-args"]: stderr.writeLine($args)
|
||||||
|
|
||||||
if args["--help"]:
|
if args["--help"]:
|
||||||
echo doc
|
stderr.writeLine(doc)
|
||||||
quit()
|
quit()
|
||||||
|
|
||||||
let now = getTime().local
|
|
||||||
|
|
||||||
let ctx = initContext(args)
|
let ctx = initContext(args)
|
||||||
|
|
||||||
## Actual command runners
|
## Actual command runners
|
||||||
if args["list"]:
|
if args["new"]:
|
||||||
|
let state =
|
||||||
|
if args["<state>"]: parseEnum[IssueState]($args["<state>"])
|
||||||
|
else: TodoToday
|
||||||
|
|
||||||
# Specific state request
|
var issue = Issue(
|
||||||
if args["<state>"]:
|
id: genUUID(),
|
||||||
let state = parseEnum[IssueState]($args["<state>"])
|
summary: $args["<summary>"],
|
||||||
ctx.loadIssues(state)
|
properties:
|
||||||
echo ctx.formatSection(ctx.issues[state], state)
|
if args["--properties"]: parsePropertiesOption($args["--properties"])
|
||||||
|
else: newTable[string,string](),
|
||||||
|
tags:
|
||||||
|
if args["--tags"]: ($args["tags"]).split(",").mapIt(it.strip)
|
||||||
|
else: newSeq[string]())
|
||||||
|
|
||||||
else:
|
ctx.tasksDir.store(issue, state)
|
||||||
|
|
||||||
let showBoth = args["--today"] == args["--future"]
|
elif args["edit"]:
|
||||||
let indent = if showBoth: " " else: ""
|
let issueId = $args["<id>"]
|
||||||
ctx.loadAllIssues()
|
|
||||||
|
|
||||||
# Today's items
|
edit(ctx.tasksDir.loadIssueById(issueId))
|
||||||
if args["--today"] or showBoth:
|
|
||||||
if showBoth: echo ctx.formatHeader("Today")
|
|
||||||
|
|
||||||
for s in [Current, TodoToday]:
|
elif args["start"] or args["do-today"] or args["done"] or
|
||||||
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
|
args["pending"] or args["todo"]:
|
||||||
echo ctx.formatSection(ctx.issues[s], s, indent)
|
|
||||||
|
|
||||||
if ctx.issues.hasKey(Done):
|
var targetState: IssueState
|
||||||
let doneIssues = ctx.issues[Done].filterIt(
|
if args["done"]: targetState = Done
|
||||||
it.properties.hasKey("completed") and
|
elif args["do-today"]: targetState = TodoToday
|
||||||
sameDay(now, it.getDateTime("completed")))
|
elif args["pending"]: targetState = Todo
|
||||||
if doneIssues.len > 0:
|
elif args["start"]: targetState = Current
|
||||||
echo ctx.formatSection(doneIssues, Done, indent)
|
elif args["todo"]: targetState = Todo
|
||||||
|
|
||||||
# Future items
|
for id in @(args["<id>"]):
|
||||||
if args["--future"] or showBoth:
|
ctx.tasksDir.moveIssue(ctx.tasksDir.loadIssueById(id), targetState)
|
||||||
if showBoth: echo ctx.formatHeader("Future")
|
|
||||||
|
|
||||||
for s in [Pending, Todo]:
|
if ctx.triggerPtk:
|
||||||
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
|
if targetState == Current:
|
||||||
echo ctx.formatSection(ctx.issues[s], s, indent)
|
let issue = ctx.tasksDir.loadIssueById($(args["<id>"][0]))
|
||||||
|
var cmd = "ptk start "
|
||||||
|
if issue.tags.len > 0: cmd &= "-g \"" & issue.tags.join(",") & "\""
|
||||||
|
cmd &= " \"" & issue.summary & "\""
|
||||||
|
discard execShellCmd(cmd)
|
||||||
|
elif targetState == Done: discard execShellCmd("ptk stop")
|
||||||
|
|
||||||
|
elif args["list"]:
|
||||||
|
|
||||||
|
let filter = initFilter()
|
||||||
|
var filterOption = none(IssueFilter)
|
||||||
|
if args["--properties"]:
|
||||||
|
filter.properties = parsePropertiesOption($args["--properties"])
|
||||||
|
filterOption = some(filter)
|
||||||
|
|
||||||
|
let stateOption =
|
||||||
|
if args["<state>"]: some(parseEnum[IssueState]($args["<state>"]))
|
||||||
|
else: none(IssueState)
|
||||||
|
|
||||||
|
let showBoth = args["--today"] == args["--future"]
|
||||||
|
ctx.list(filterOption, stateOption, showBoth or args["--today"],
|
||||||
|
showBoth or args["--future"])
|
||||||
|
|
||||||
|
if ctx.autoList and not args["list"]:
|
||||||
|
ctx.loadAllIssues()
|
||||||
|
ctx.list(none(IssueFilter), none(IssueState), true, true)
|
||||||
|
|
||||||
except:
|
except:
|
||||||
fatal "pit: " & getCurrentExceptionMsg()
|
fatal "pit: " & getCurrentExceptionMsg()
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import cliutils, options, os, ospaths, sequtils, strutils, tables, times, uuids
|
import cliutils, options, os, ospaths, sequtils, strutils, tables, times, timeutils, uuids
|
||||||
|
|
||||||
from nre import re, match
|
from nre import re, match
|
||||||
type
|
type
|
||||||
@ -16,7 +16,10 @@ type
|
|||||||
Done = "done",
|
Done = "done",
|
||||||
Todo = "todo"
|
Todo = "todo"
|
||||||
|
|
||||||
const ISO8601Format* = "yyyy:MM:dd'T'HH:mm:sszzz"
|
IssueFilter* = ref object
|
||||||
|
properties*: TableRef[string, string]
|
||||||
|
completedRange*: tuple[b, e: DateTime]
|
||||||
|
|
||||||
const DONE_FOLDER_FORMAT* = "yyyy-MM"
|
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 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"
|
||||||
@ -36,11 +39,37 @@ proc `[]`*(issue: Issue, key: string): string =
|
|||||||
proc `[]=`*(issue: Issue, key: string, value: string) =
|
proc `[]=`*(issue: Issue, key: string, value: string) =
|
||||||
issue.properties[key] = value
|
issue.properties[key] = value
|
||||||
|
|
||||||
|
proc hasProp*(issue: Issue, key: string): bool =
|
||||||
|
return issue.properties.hasKey(key)
|
||||||
|
|
||||||
proc getDateTime*(issue: Issue, key: string): DateTime =
|
proc getDateTime*(issue: Issue, key: string): DateTime =
|
||||||
return parse(issue.properties[key], ISO8601Format)
|
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) =
|
proc setDateTime*(issue: Issue, key: string, dt: DateTime) =
|
||||||
issue.properties[key] = format(dt, ISO8601Format)
|
issue.properties[key] = dt.formatIso8601
|
||||||
|
|
||||||
|
proc initFilter*(): IssueFilter =
|
||||||
|
result = IssueFilter(
|
||||||
|
properties: newTable[string,string](),
|
||||||
|
completedRange: (fromUnix(0).local, fromUnix(253400659199).local))
|
||||||
|
|
||||||
|
proc initFilter*(props: TableRef[string, string]): IssueFilter =
|
||||||
|
if isNil(props):
|
||||||
|
raise newException(ValueError,
|
||||||
|
"cannot initialize property filter without properties")
|
||||||
|
|
||||||
|
result = IssueFilter(
|
||||||
|
properties: props,
|
||||||
|
completedRange: (fromUnix(0).local, fromUnix(253400659199).local))
|
||||||
|
|
||||||
|
proc initFilter*(range: tuple[b, e: DateTime]): IssueFilter =
|
||||||
|
result = IssueFilter(
|
||||||
|
properties: newTable[string, string](),
|
||||||
|
completedRange: range)
|
||||||
|
|
||||||
## Parse and format issues
|
## Parse and format issues
|
||||||
proc fromStorageFormat*(id: string, issueTxt: string): Issue =
|
proc fromStorageFormat*(id: string, issueTxt: string): Issue =
|
||||||
@ -86,24 +115,44 @@ proc fromStorageFormat*(id: string, issueTxt: string): Issue =
|
|||||||
|
|
||||||
result.details = if detailLines.len > 0: detailLines.join("\n") else: ""
|
result.details = if detailLines.len > 0: detailLines.join("\n") else: ""
|
||||||
|
|
||||||
proc toStorageFormat*(issue: Issue): string =
|
proc toStorageFormat*(issue: Issue, withComments = false): string =
|
||||||
var lines = @[issue.summary]
|
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: lines.add(key & ": " & val)
|
for key, val in issue.properties: lines.add(key & ": " & val)
|
||||||
if issue.tags.len > 0: lines.add("tags: " & issue.tags.join(","))
|
if issue.tags.len > 0: lines.add("tags: " & issue.tags.join(","))
|
||||||
if not isNilOrWhitespace(issue.details):
|
if not isNilOrWhitespace(issue.details) or withComments:
|
||||||
|
if withComments: lines.add("# Details go below the \"--------\"")
|
||||||
lines.add("--------")
|
lines.add("--------")
|
||||||
lines.add(issue.details)
|
lines.add(issue.details)
|
||||||
|
|
||||||
result = lines.join("\n")
|
result = lines.join("\n")
|
||||||
|
|
||||||
## Load and store from filesystem
|
## Load and store from filesystem
|
||||||
proc loadIssue*(filePath: string): Issue =
|
proc loadIssue*(filePath: string): Issue =
|
||||||
result = fromStorageFormat(splitFile(filePath).name, readFile(filePath))
|
result = fromStorageFormat(splitFile(filePath).name, readFile(filePath))
|
||||||
result.filepath = filePath
|
result.filepath = filePath
|
||||||
|
|
||||||
proc storeIssue*(dirPath: string, issue: Issue) =
|
proc loadIssueById*(tasksDir, id: string): Issue =
|
||||||
issue.filepath = joinPath(dirPath, $issue.id & ".txt")
|
for path in walkDirRec(tasksDir):
|
||||||
writeFile(issue.filepath, toStorageFormat(issue))
|
if path.splitFile.name.startsWith(id):
|
||||||
|
return loadIssue(path)
|
||||||
|
raise newException(KeyError, "cannot find issue for id: " & id)
|
||||||
|
|
||||||
|
proc store*(issue: Issue, withComments = false) =
|
||||||
|
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()
|
||||||
|
|
||||||
proc loadIssues*(path: string): seq[Issue] =
|
proc loadIssues*(path: string): seq[Issue] =
|
||||||
result = @[]
|
result = @[]
|
||||||
@ -111,12 +160,27 @@ proc loadIssues*(path: string): seq[Issue] =
|
|||||||
if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome():
|
if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome():
|
||||||
result.add(loadIssue(path))
|
result.add(loadIssue(path))
|
||||||
|
|
||||||
|
proc moveIssue*(tasksDir: string, issue: Issue, newState: IssueState) =
|
||||||
|
removeFile(issue.filepath)
|
||||||
|
if newState == Done: issue.setDateTime("completed", getTime().local)
|
||||||
|
tasksDir.store(issue, newState)
|
||||||
|
|
||||||
## Utilities for working with issue collections.
|
## Utilities for working with issue collections.
|
||||||
proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] =
|
proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] =
|
||||||
result = newTable[string, seq[Issue]]()
|
result = newTable[string, seq[Issue]]()
|
||||||
for i in issues:
|
for i in issues:
|
||||||
let key = if i.properties.hasKey(propertyKey): i[propertyKey] else: ""
|
let key = if i.hasProp(propertyKey): i[propertyKey] else: ""
|
||||||
if not result.hasKey(key): result[key] = newSeq[Issue]()
|
if not result.hasKey(key): result[key] = newSeq[Issue]()
|
||||||
result[key].add(i)
|
result[key].add(i)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
result = result.filterIt(not it.hasProp("completed") or
|
||||||
|
it.getDateTime("completed").between(
|
||||||
|
filter.completedRange.b,
|
||||||
|
filter.completedRange.e))
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user