Compare commits

...

9 Commits

6 changed files with 108 additions and 41 deletions

View File

@ -46,7 +46,7 @@ Some other common properties I use are:
- `resolution`: for short notes about why an issue was moved to `done`, - `resolution`: for short notes about why an issue was moved to `done`,
especially if it the action wasn't taken or if it is not completely clear especially if it the action wasn't taken or if it is not completely clear
that this issue was completed. that this issue was completed.
## Configuration Options ## Configuration Options
@ -107,7 +107,7 @@ in the configuration file. All options are optional unless stated otherwise.
- `defaultContext`: if present all invokations to the CLI will - `defaultContext`: if present all invokations to the CLI will
be in this context. This is like adding a `--context <defaultContext>` be in this context. This is like adding a `--context <defaultContext>`
parameter to every CLI invocation. Any actual `--context` parameter will parameter to every CLI invocation. Any actual `--context` parameter will
override this value. override this value.
- `verbose`: Show issue details when listing issues (same as - `verbose`: Show issue details when listing issues (same as
`--verbose` flag). `--verbose` flag).
@ -135,7 +135,4 @@ in the configuration file. All options are optional unless stated otherwise.
"Personal"; it does not need an alternate display name. "Personal"; it does not need an alternate display name.
* `tasksDir` **required**: a file path to the root directory for the issue * `tasksDir` **required**: a file path to the root directory for the issue
repository (same as `--tasks-dir` CLI parameter). repository (same as `--tasks-dir` CLI parameter).
- CLI parameter: *cannot be specified via CLI*
- config file key: `contexts`

View File

@ -1,6 +1,6 @@
# Package # Package
version = "4.9.1" version = "4.13.0"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Personal issue tracker." description = "Personal issue tracker."
license = "MIT" license = "MIT"
@ -10,15 +10,16 @@ bin = @["pit", "pit_api"]
# Dependencies # Dependencies
requires @[ requires @[
"nim >= 0.19.0", "nim >= 1.4.0",
"docopt 0.6.8", "docopt 0.6.8",
"jester 0.4.1", "jester 0.5.0",
"uuids 0.1.10", "uuids 0.1.10",
"https://git.jdb-labs.com/jdb/nim-cli-utils.git >= 0.6.4", "https://git.jdb-software.com/jdb/nim-cli-utils.git >= 0.6.4",
"https://git.jdb-labs.com/jdb/nim-lang-utils.git >= 0.4.0", "https://git.jdb-software.com/jdb/nim-lang-utils.git >= 0.4.0",
"https://git.jdb-labs.com/jdb/nim-time-utils.git >= 0.4.0", "https://git.jdb-software.com/jdb/nim-time-utils.git >= 0.4.0",
"https://git.jdb-labs.com/jdb/update-nim-package-version" "https://git.jdb-software.com/jdb/nim-data-uri.git >= 1.0.0",
"https://git.jdb-software.com/jdb/update-nim-package-version"
] ]
task updateVersion, "Update the version of this package.": task updateVersion, "Update the version of this package.":

View File

@ -1,8 +1,8 @@
## Personal Issue Tracker CLI interface ## Personal Issue Tracker CLI interface
## ==================================== ## ====================================
import cliutils, docopt, json, logging, options, os, sequtils, import algorithm, cliutils, data_uri, docopt, json, logging, options, os,
std/wordwrap, tables, terminal, times, timeutils, unicode, uuids sequtils, std/wordwrap, tables, terminal, times, timeutils, unicode, uuids
from nre import re from nre import re
import strutils except alignLeft, capitalize, strip, toUpper, toLower import strutils except alignLeft, capitalize, strip, toUpper, toLower
@ -70,6 +70,8 @@ proc formatIssue(ctx: CliContext, issue: Issue): string =
if not issue.details.isEmptyOrWhitespace: if not issue.details.isEmptyOrWhitespace:
result &= issue.details.strip.withColor(fgCyan) & "\n" result &= issue.details.strip.withColor(fgCyan) & "\n"
result &= termReset
proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "", proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
verbose = false): string = verbose = false): string =
@ -199,6 +201,10 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState
if state.isSome: if state.isSome:
ctx.loadIssues(state.get) ctx.loadIssues(state.get)
if filter.isSome: ctx.filterIssues(filter.get) if filter.isSome: ctx.filterIssues(filter.get)
if state.get == Done and showToday:
ctx.issues[Done] = ctx.issues[Done].filterIt(
it.hasProp("completed") and
sameDay(getTime().local, it.getDateTime("completed")))
stdout.write ctx.formatSection(ctx.issues[state.get], state.get, "", verbose) stdout.write ctx.formatSection(ctx.issues[state.get], state.get, "", verbose)
return return
@ -221,20 +227,17 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0: if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
stdout.write ctx.formatSection(ctx.issues[s], s, indent, verbose) stdout.write ctx.formatSection(ctx.issues[s], s, indent, verbose)
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:
stdout.write ctx.formatSection(doneIssues, Done, indent, verbose)
# Future items # Future items
if future: if future:
if today: ctx.writeHeader("Future") if today: ctx.writeHeader("Future")
for s in [Pending, Todo]: for s in [Pending, Todo]:
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0: if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
stdout.write ctx.formatSection(ctx.issues[s], s, indent, verbose) let visibleIssues = ctx.issues[s].filterIt(
not (it.hasProp("hide-until") and
it.getDateTime("hide-until") > getTime().local))
stdout.write ctx.formatSection(visibleIssues, s, indent, verbose)
when isMainModule: when isMainModule:
@ -242,14 +245,18 @@ when isMainModule:
let doc = """ let doc = """
Usage: Usage:
pit ( new | add) <summary> [<state>] [options] pit ( new | add) <summary> [<state>] [options]
pit list [<listable>] [options] pit list contexts [options]
pit list [<stateOrId>] [options]
pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options] pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options]
pit edit <ref>... pit edit <ref>... [options]
pit tag <id>... [options] pit tag <id>... [options]
pit untag <id>... [options] pit untag <id>... [options]
pit reorder <state> pit reorder <state> [options]
pit delegate <id> <delegated-to> pit delegate <id> <delegated-to>
pit ( delete | rm ) <id>... pit hide-until <id> <date> [options]
pit ( delete | rm ) <id>... [options]
pit add-binary-property <id> <propName> <propSource> [options]
pit get-binary-property <id> <propName> <propDest> [options]
Options: Options:
@ -421,6 +428,13 @@ Options:
elif targetState == Done or targetState == Pending: elif targetState == Done or targetState == Pending:
discard execShellCmd("ptk stop") discard execShellCmd("ptk stop")
elif args["hide-until"]:
let issue = ctx.tasksDir.loadIssueById($(args["<id>"]))
issue.setDateTime("hide-until", parseDate($args["<date>"]))
issue.store()
elif args["delegate"]: elif args["delegate"]:
let issue = ctx.tasksDir.loadIssueById($(args["<id>"])) let issue = ctx.tasksDir.loadIssueById($(args["<id>"]))
@ -465,6 +479,10 @@ Options:
filter.properties["context"] = ctx.defaultContext.get filter.properties["context"] = ctx.defaultContext.get
filterOption = some(filter) filterOption = some(filter)
if args["--tags"]:
filter.hasTags = ($args["--tags"]).split(',')
filterOption = some(filter)
# Finally, if the "context" is "all", don't filter on context # Finally, if the "context" is "all", don't filter on context
if filter.properties.hasKey("context") and if filter.properties.hasKey("context") and
filter.properties["context"] == "all": filter.properties["context"] == "all":
@ -475,11 +493,10 @@ Options:
var stateOption = none(IssueState) var stateOption = none(IssueState)
var issueIdOption = none(string) var issueIdOption = none(string)
if args["<listable>"]: if args["contexts"]: listContexts = true
if $args["<listable>"] == "contexts": listContexts = true elif args["<stateOrId>"]:
else: try: stateOption = some(parseEnum[IssueState]($args["<stateOrId>"]))
try: stateOption = some(parseEnum[IssueState]($args["<listable>"])) except: issueIdOption = some($args["<stateOrId>"])
except: issueIdOption = some($args["<listable>"])
# List the known contexts # List the known contexts
if listContexts: if listContexts:
@ -495,7 +512,7 @@ Options:
else: b else: b
).len ).len
for c in uniqContexts: for c in uniqContexts.sorted:
stdout.writeLine(c.alignLeft(maxLen+2) & ctx.getIssueContextDisplayName(c)) stdout.writeLine(c.alignLeft(maxLen+2) & ctx.getIssueContextDisplayName(c))
# List a specific issue # List a specific issue
@ -510,6 +527,33 @@ Options:
showBoth or args["--future"], showBoth or args["--future"],
ctx.verbose) ctx.verbose)
elif args["add-binary-property"]:
let issue = ctx.tasksDir.loadIssueById($(args["<id>"]))
let propIn =
if $(args["<propSource>"]) == "-": stdin
else: open($(args["<propSource>"]))
try: issue[$(args["<propName>"])] = encodeAsDataUri(readAll(propIn))
finally: close(propIn)
issue.store()
elif args["get-binary-property"]:
let issue = ctx.tasksDir.loadIssueById($(args["<id>"]))
if not issue.hasProp($(args["<propName>"])):
raise newException(Exception,
"issue " & ($issue.id)[0..<6] & " has no property name '" &
$(args["<propName>"]) & "'")
let propOut =
if $(args["<propDest>"]) == "-": stdout
else: open($(args["<propDest>"]), fmWrite)
try: write(propOut, decodeDataUri(issue[$(args["<propName>"])]))
finally: close(propOut)
except: except:
fatal "pit: " & getCurrentExceptionMsg() fatal "pit: " & getCurrentExceptionMsg()
#raise getCurrentException() #raise getCurrentException()

View File

@ -20,7 +20,7 @@ proc raiseEx(reason: string): void = raise newException(Exception, reason)
template halt(code: HttpCode, template halt(code: HttpCode,
headers: RawHeaders, headers: RawHeaders,
content: string): typed = content: string): void =
## Immediately replies with the specified request. This means any further ## Immediately replies with the specified request. This means any further
## code will not be executed after calling this template in the current ## code will not be executed after calling this template in the current
## route. ## route.

View File

@ -22,6 +22,7 @@ type
IssueFilter* = ref object IssueFilter* = ref object
completedRange*: Option[tuple[b, e: DateTime]] completedRange*: Option[tuple[b, e: DateTime]]
fullMatch*, summaryMatch*: Option[Regex] fullMatch*, summaryMatch*: Option[Regex]
hasTags*: seq[string]
properties*: TableRef[string, string] properties*: TableRef[string, string]
PitConfig* = ref object PitConfig* = ref object
@ -69,6 +70,7 @@ proc initFilter*(): IssueFilter =
completedRange: none(tuple[b, e: DateTime]), completedRange: none(tuple[b, e: DateTime]),
fullMatch: none(Regex), fullMatch: none(Regex),
summaryMatch: none(Regex), summaryMatch: none(Regex),
hasTags: @[],
properties: newTable[string, string]()) properties: newTable[string, string]())
proc propsFilter*(props: TableRef[string, string]): IssueFilter = proc propsFilter*(props: TableRef[string, string]): IssueFilter =
@ -91,6 +93,10 @@ proc fullMatchFilter*(pattern: string): IssueFilter =
result = initFilter() result = initFilter()
result.fullMatch = some(re("(?i)" & pattern)) 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]] = 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:
@ -99,6 +105,23 @@ proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Iss
result[key].add(i) 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 ## Parse and format issues
proc fromStorageFormat*(id: string, issueTxt: string): Issue = proc fromStorageFormat*(id: string, issueTxt: string): Issue =
type ParseState = enum ReadingSummary, ReadingProps, ReadingDetails type ParseState = enum ReadingSummary, ReadingProps, ReadingDetails
@ -170,6 +193,7 @@ proc loadIssueById*(tasksDir, id: string): Issue =
raise newException(KeyError, "cannot find issue for id: " & id) raise newException(KeyError, "cannot find issue for id: " & id)
proc store*(issue: Issue, withComments = false) = proc store*(issue: Issue, withComments = false) =
discard existsOrCreateDir(issue.filePath.parentDir)
writeFile(issue.filepath, toStorageFormat(issue, withComments)) writeFile(issue.filepath, toStorageFormat(issue, withComments))
proc store*(tasksDir: string, issue: Issue, state: IssueState, withComments = false) = proc store*(tasksDir: string, issue: Issue, state: IssueState, withComments = false) =
@ -258,6 +282,9 @@ proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
let p = filter.fullMatch.get let p = filter.fullMatch.get
result = result.filterIt( it.summary.find(p).isSome or it.details.find(p).isSome) 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 ### Configuration utilities
proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitConfig = proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitConfig =
let pitrcLocations = @[ let pitrcLocations = @[
@ -265,9 +292,9 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
".pitrc", $getEnv("PITRC"), $getEnv("HOME") & "/.pitrc"] ".pitrc", $getEnv("PITRC"), $getEnv("HOME") & "/.pitrc"]
var pitrcFilename: string = var pitrcFilename: string =
foldl(pitrcLocations, if len(a) > 0: a elif existsFile(b): b else: "") foldl(pitrcLocations, if len(a) > 0: a elif fileExists(b): b else: "")
if not existsFile(pitrcFilename): if not fileExists(pitrcFilename):
warn "pit: could not find .pitrc file: " & pitrcFilename warn "pit: could not find .pitrc file: " & pitrcFilename
if isEmptyOrWhitespace(pitrcFilename): if isEmptyOrWhitespace(pitrcFilename):
pitrcFilename = $getEnv("HOME") & "/.pitrc" pitrcFilename = $getEnv("HOME") & "/.pitrc"
@ -298,12 +325,10 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
if isEmptyOrWhitespace(result.tasksDir): if isEmptyOrWhitespace(result.tasksDir):
raise newException(Exception, "no tasks directory configured") raise newException(Exception, "no tasks directory configured")
if not existsDir(result.tasksDir): if not dirExists(result.tasksDir):
raise newException(Exception, "cannot find tasks dir: " & result.tasksDir) raise newException(Exception, "cannot find tasks dir: " & result.tasksDir)
# Create our tasks directory structure if needed # Create our tasks directory structure if needed
for s in IssueState: for s in IssueState:
if not existsDir(result.tasksDir / $s): if not dirExists(result.tasksDir / $s):
(result.tasksDir / $s).createDir (result.tasksDir / $s).createDir

View File

@ -1 +1 @@
const PIT_VERSION* = "4.9.1" const PIT_VERSION* = "4.13.0"