Compare commits

...

6 Commits

5 changed files with 45 additions and 36 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.0" version = "4.10.0"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Personal issue tracker." description = "Personal issue tracker."
license = "MIT" license = "MIT"
@ -10,9 +10,9 @@ 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-labs.com/jdb/nim-cli-utils.git >= 0.6.4",

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, docopt, json, logging, options, os, sequtils,
tables, terminal, times, timeutils, unicode, uuids 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
@ -49,7 +49,7 @@ proc initContext(args: Table[string, Value]): CliContext =
proc getIssueContextDisplayName(ctx: CliContext, context: string): string = proc getIssueContextDisplayName(ctx: CliContext, context: string): string =
if not ctx.contexts.hasKey(context): if not ctx.contexts.hasKey(context):
if context.isNilOrWhitespace: return "<default>" if context.isEmptyOrWhitespace: return "<default>"
else: return context.capitalize() else: return context.capitalize()
return ctx.contexts[context] return ctx.contexts[context]
@ -67,7 +67,7 @@ proc formatIssue(ctx: CliContext, issue: Issue): string =
result &= "--------".withColor(fgBlack, true) & "\n" result &= "--------".withColor(fgBlack, true) & "\n"
if not issue.details.isNilOrWhitespace: if not issue.details.isEmptyOrWhitespace:
result &= issue.details.strip.withColor(fgCyan) & "\n" result &= issue.details.strip.withColor(fgCyan) & "\n"
proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "", proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
@ -75,7 +75,7 @@ proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
result = "" result = ""
var showDetails = not issue.details.isNilOrWhitespace and verbose var showDetails = not issue.details.isEmptyOrWhitespace and verbose
var prefixLen = 0 var prefixLen = 0
var summaryIndentLen = indent.len + 7 var summaryIndentLen = indent.len + 7
@ -83,7 +83,7 @@ proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
if issue.hasProp("delegated-to"): prefixLen += issue["delegated-to"].len + 2 # space for the ':' and ' ' if issue.hasProp("delegated-to"): prefixLen += issue["delegated-to"].len + 2 # space for the ':' and ' '
# Wrap and write the summary. # Wrap and write the summary.
var wrappedSummary = ("+".repeat(prefixLen) & issue.summary).wordWrap(width - summaryIndentLen).indent(summaryIndentLen) var wrappedSummary = ("+".repeat(prefixLen) & issue.summary).wrapWords(width - summaryIndentLen).indent(summaryIndentLen)
wrappedSummary = wrappedSummary[(prefixLen + summaryIndentLen)..^1] wrappedSummary = wrappedSummary[(prefixLen + summaryIndentLen)..^1]
@ -103,7 +103,7 @@ proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
if issue.hasProp("pending"): if issue.hasProp("pending"):
let startIdx = "Pending: ".len let startIdx = "Pending: ".len
var pendingText = issue["pending"].wordWrap(width - startIdx - summaryIndentLen) var pendingText = issue["pending"].wrapWords(width - startIdx - summaryIndentLen)
.indent(startIdx) .indent(startIdx)
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(summaryIndentLen) pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(summaryIndentLen)
result &= "\n" & pendingText.withColor(fgCyan) result &= "\n" & pendingText.withColor(fgCyan)
@ -242,7 +242,8 @@ 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
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>...
pit tag <id>... [options] pit tag <id>... [options]
@ -465,6 +466,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 +480,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 +499,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

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:
@ -122,7 +128,7 @@ proc fromStorageFormat*(id: string, issueTxt: string): Issue =
of ReadingProps: of ReadingProps:
# Ignore empty lines # Ignore empty lines
if line.isNilOrWhitespace: continue if line.isEmptyOrWhitespace: continue
# Look for the sentinal to start parsing as detail lines # Look for the sentinal to start parsing as detail lines
if line == "--------": if line == "--------":
@ -149,9 +155,9 @@ proc toStorageFormat*(issue: Issue, withComments = false): string =
lines.add(issue.summary) lines.add(issue.summary)
if withComments: lines.add("# Properties (\"key:value\" per line):") if withComments: lines.add("# Properties (\"key:value\" per line):")
for key, val in issue.properties: for key, val in issue.properties:
if not val.isNilOrWhitespace: lines.add(key & ": " & val) if not val.isEmptyOrWhitespace: 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) or withComments: if not isEmptyOrWhitespace(issue.details) or withComments:
if withComments: lines.add("# Details go below the \"--------\"") if withComments: lines.add("# Details go below the \"--------\"")
lines.add("--------") lines.add("--------")
lines.add(issue.details) lines.add(issue.details)
@ -170,6 +176,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) =
@ -202,7 +209,7 @@ proc loadIssues*(path: string): seq[Issue] =
toSeq(orderFile.lines) toSeq(orderFile.lines)
.mapIt(it.split(' ')[0]) .mapIt(it.split(' ')[0])
.deduplicate .deduplicate
.filterIt(not it.startsWith("> ") and not it.isNilOrWhitespace) .filterIt(not it.startsWith("> ") and not it.isEmptyOrWhitespace)
else: newSeq[string]() else: newSeq[string]()
type TaggedIssue = tuple[issue: Issue, ordered: bool] type TaggedIssue = tuple[issue: Issue, ordered: bool]
@ -258,6 +265,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,11 +275,11 @@ 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 isNilOrWhitespace(pitrcFilename): if isEmptyOrWhitespace(pitrcFilename):
pitrcFilename = $getEnv("HOME") & "/.pitrc" pitrcFilename = $getEnv("HOME") & "/.pitrc"
var cfgFile: File var cfgFile: File
try: try:
@ -295,15 +305,13 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
for k, v in cfgJson["contexts"]: for k, v in cfgJson["contexts"]:
result.contexts[k] = v.getStr() result.contexts[k] = v.getStr()
if isNilOrWhitespace(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.0" const PIT_VERSION* = "4.10.0"