Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d1dc7512a | |||
| 1d18be9d1b | |||
| 6ceca9b009 |
@@ -1,6 +1,6 @@
|
||||
# Package
|
||||
|
||||
version = "4.30.1"
|
||||
version = "4.31.1"
|
||||
author = "Jonathan Bernard"
|
||||
description = "Personal issue tracker."
|
||||
license = "MIT"
|
||||
|
||||
150
src/pit.nim
150
src/pit.nim
@@ -1,10 +1,11 @@
|
||||
## Personal Issue Tracker CLI interface
|
||||
## ====================================
|
||||
|
||||
import std/[algorithm, logging, options, os, sequtils, tables, times, unicode]
|
||||
import data_uri, docopt, json, timeutils, uuids
|
||||
import std/[algorithm, logging, options, os, sequtils, sets, tables, terminal,
|
||||
times, unicode]
|
||||
import cliutils, data_uri, docopt, json, timeutils, uuids, zero_functional
|
||||
|
||||
from nre import re
|
||||
from nre import match, re
|
||||
import strutils except alignLeft, capitalize, strip, toUpper, toLower
|
||||
import pit/[cliconstants, formatting, libpit, projects, sync_pbm_vsb]
|
||||
|
||||
@@ -18,18 +19,16 @@ 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"
|
||||
if pair.len == 1: result[pair[0]] = MATCH_ANY
|
||||
else: result[pair[0]] = pair[1]
|
||||
|
||||
|
||||
proc parseExclPropertiesOption(propsOpt: string): TableRef[string, seq[string]] =
|
||||
result = newTable[string, seq[string]]()
|
||||
for propText in propsOpt.split(";"):
|
||||
let pair = propText.split(":", 1)
|
||||
let val =
|
||||
if pair.len == 1: "true"
|
||||
else: pair[1]
|
||||
if result.hasKey(pair[0]): result[pair[0]].add(val)
|
||||
else: result[pair[0]] = @[val]
|
||||
if not result.hasKey(pair[0]): result[pair[0]] = @[]
|
||||
if pair.len == 2: result[pair[0]].add(pair[1])
|
||||
|
||||
|
||||
proc reorder(ctx: CliContext, state: IssueState) =
|
||||
@@ -38,6 +37,89 @@ proc reorder(ctx: CliContext, state: IssueState) =
|
||||
ctx.loadIssues(state)
|
||||
discard os.execShellCmd(EDITOR & " '" & (ctx.cfg.tasksDir / $state / "order.txt") & "' </dev/tty >/dev/tty")
|
||||
|
||||
|
||||
proc addIssue(
|
||||
ctx: CliContext,
|
||||
args: Table[string, Value],
|
||||
propertiesOption = none[TableRef[string, string]](),
|
||||
tagsOption = none[seq[string]]()): Issue =
|
||||
|
||||
let state =
|
||||
if args["<state>"]: parseEnum[IssueState]($args["<state>"])
|
||||
else: TodoToday
|
||||
|
||||
var issueProps = propertiesOption.get(newTable[string,string]())
|
||||
if not issueProps.hasKey("created"): issueProps["created"] = getTime().local.formatIso8601
|
||||
if not issueProps.hasKey("context") and ctx.defaultContext.isSome():
|
||||
stderr.writeLine("Using default context: " & ctx.defaultContext.get)
|
||||
issueProps["context"] = ctx.defaultContext.get
|
||||
|
||||
if not args["--non-interactive"]:
|
||||
# look for default properties for this context
|
||||
let globalDefaultProps =
|
||||
if ctx.cfg.defaultPropertiesByContext.hasKey("<all>"):
|
||||
ctx.cfg.defaultPropertiesByContext["<all>"]
|
||||
else: newSeq[string]()
|
||||
|
||||
let contextDefaultProps =
|
||||
if issueProps.hasKey("context") and
|
||||
ctx.cfg.defaultPropertiesByContext.hasKey(issueProps["context"]):
|
||||
ctx.cfg.defaultPropertiesByContext[issueProps["context"]]
|
||||
else: newSeq[string]()
|
||||
|
||||
let defaultProps = toOrderedSet(globalDefaultProps & contextDefaultProps)
|
||||
if defaultProps.len > 0:
|
||||
ctx.loadAllIssues()
|
||||
if issueProps.hasKey("context"):
|
||||
ctx.filterIssues(propsFilter(newTable({"context": issueProps["context"]})))
|
||||
|
||||
let numberRegex = re("^[0-9]+$")
|
||||
for propName in defaultProps:
|
||||
if not issueProps.hasKey(propName):
|
||||
let allIssues: seq[seq[Issue]] = toSeq(values(ctx.issues))
|
||||
let previousValues = toSeq(toHashSet(allIssues -->
|
||||
flatten()
|
||||
.filter(it.hasProp(propName))
|
||||
.map(it[propName])))
|
||||
|
||||
let idxValPairs: seq[tuple[key: int, val: string]] = toSeq(pairs(previousValues))
|
||||
let previousValuesDisplay: seq[string] = idxValPairs -->
|
||||
map(" " & $it[0] & " - " & it[1])
|
||||
|
||||
stdout.write(
|
||||
"Previous values for property '" & propName & "':\p" &
|
||||
previousValuesDisplay.join("\p") & "\p" &
|
||||
"Do you want to set a value for '" & propName & "'? " &
|
||||
"You can use the numbers above to use an existing value, enter " &
|
||||
"something new, or leave blank to indicate no value.\p" &
|
||||
withColor(propName, fgMagenta) & ":" &
|
||||
withColor(" ", fgBlue, bright=true, skipReset=true))
|
||||
|
||||
let resp = stdin.readLine.strip
|
||||
let numberResp = resp.match(numberRegex)
|
||||
|
||||
if numberResp.isSome:
|
||||
let idx = parseInt(resp)
|
||||
if idx >= 0 and idx < previousValues.len:
|
||||
issueProps[propName] = previousValues[idx]
|
||||
|
||||
elif resp.len > 0:
|
||||
issueProps[propName] = resp
|
||||
|
||||
stdout.writeLine(termReset)
|
||||
|
||||
result = Issue(
|
||||
id: genUUID(),
|
||||
summary: $args["<summary>"],
|
||||
properties: issueProps,
|
||||
tags:
|
||||
if tagsOption.isSome: tagsOption.get
|
||||
else: newSeq[string]())
|
||||
|
||||
ctx.cfg.tasksDir.store(result, state)
|
||||
stdout.writeLine "\p" & formatIssue(result)
|
||||
|
||||
|
||||
proc edit(issue: Issue) =
|
||||
|
||||
# Write format comments (to help when editing)
|
||||
@@ -89,8 +171,6 @@ when isMainModule:
|
||||
var exclTagsOption = none(seq[string])
|
||||
|
||||
let filter = initFilter()
|
||||
var filterOption = none(IssueFilter)
|
||||
|
||||
|
||||
if args["--properties"] or args["--context"]:
|
||||
|
||||
@@ -124,43 +204,37 @@ when isMainModule:
|
||||
# Initialize filter with properties (if given)
|
||||
if propertiesOption.isSome:
|
||||
filter.properties = propertiesOption.get
|
||||
filterOption = some(filter)
|
||||
|
||||
# Add property exclusions (if given)
|
||||
if exclPropsOption.isSome:
|
||||
filter.exclProperties = exclPropsOption.get
|
||||
filterOption = some(filter)
|
||||
|
||||
# If they supplied text matches, add that to the filter.
|
||||
if args["--match"]:
|
||||
filter.summaryMatch = some(re("(?i)" & $args["--match"]))
|
||||
filterOption = some(filter)
|
||||
|
||||
if args["--match-all"]:
|
||||
filter.fullMatch = some(re("(?i)" & $args["--match-all"]))
|
||||
filterOption = some(filter)
|
||||
|
||||
# If no "context" property is given, use the default (if we have one)
|
||||
if ctx.defaultContext.isSome and not filter.properties.hasKey("context"):
|
||||
stderr.writeLine("Limiting to default context: " & ctx.defaultContext.get)
|
||||
filter.properties["context"] = ctx.defaultContext.get
|
||||
filterOption = some(filter)
|
||||
|
||||
if tagsOption.isSome:
|
||||
filter.hasTags = tagsOption.get
|
||||
filterOption = some(filter)
|
||||
|
||||
if exclTagsOption.isSome:
|
||||
filter.exclTags = exclTagsOption.get
|
||||
filterOption = some(filter)
|
||||
|
||||
if args["--today"]:
|
||||
filter.inclStates.add(@[Current, TodoToday, Pending])
|
||||
filterOption = some(filter)
|
||||
|
||||
if args["--future"]:
|
||||
filter.inclStates.add(@[Pending, Todo])
|
||||
filterOption = some(filter)
|
||||
|
||||
if args["--show-hidden"]:
|
||||
filter.exclHidden = false
|
||||
|
||||
# Finally, if the "context" is "all", don't filter on context
|
||||
if filter.properties.hasKey("context") and
|
||||
@@ -171,27 +245,7 @@ when isMainModule:
|
||||
|
||||
## Actual command runners
|
||||
if args["new"] or args["add"]:
|
||||
let state =
|
||||
if args["<state>"]: parseEnum[IssueState]($args["<state>"])
|
||||
else: TodoToday
|
||||
|
||||
var issueProps = propertiesOption.get(newTable[string,string]())
|
||||
if not issueProps.hasKey("created"): issueProps["created"] = getTime().local.formatIso8601
|
||||
if not issueProps.hasKey("context") and ctx.defaultContext.isSome():
|
||||
stderr.writeLine("Using default context: " & ctx.defaultContext.get)
|
||||
issueProps["context"] = ctx.defaultContext.get
|
||||
|
||||
var issue = Issue(
|
||||
id: genUUID(),
|
||||
summary: $args["<summary>"],
|
||||
properties: issueProps,
|
||||
tags:
|
||||
if tagsOption.isSome: tagsOption.get
|
||||
else: newSeq[string]())
|
||||
|
||||
ctx.cfg.tasksDir.store(issue, state)
|
||||
updatedIssues.add(issue)
|
||||
stdout.writeLine formatIssue(issue)
|
||||
updatedIssues.add(ctx.addIssue(args, propertiesOption, tagsOption))
|
||||
|
||||
elif args["reorder"]:
|
||||
ctx.reorder(parseEnum[IssueState]($args["<state>"]))
|
||||
@@ -318,7 +372,7 @@ when isMainModule:
|
||||
|
||||
let issue = ctx.cfg.tasksDir.loadIssueById(id)
|
||||
|
||||
if not args["--yes"]:
|
||||
if not args["--non-interactive"]:
|
||||
stderr.write("Delete '" & issue.summary & "' (y/n)? ")
|
||||
if not "yes".startsWith(stdin.readLine.toLower):
|
||||
continue
|
||||
@@ -370,7 +424,7 @@ when isMainModule:
|
||||
for state in statesOption.get: ctx.loadIssues(state)
|
||||
else: ctx.loadAllIssues()
|
||||
|
||||
if filterOption.isSome: ctx.filterIssues(filterOption.get)
|
||||
ctx.filterIssues(filter)
|
||||
|
||||
for state, issueList in ctx.issues:
|
||||
for issue in issueList:
|
||||
@@ -386,27 +440,27 @@ when isMainModule:
|
||||
stdout.writeLine formatIssue(issue)
|
||||
|
||||
# List projects
|
||||
elif listProjects: ctx.listProjects(filterOption)
|
||||
elif listProjects: ctx.listProjects(some(filter))
|
||||
|
||||
# List milestones
|
||||
elif listMilestones: ctx.listMilestones(filterOption)
|
||||
elif listMilestones: ctx.listMilestones(some(filter))
|
||||
|
||||
# List all issues
|
||||
else:
|
||||
trace "listing all issues"
|
||||
let showBoth = args["--today"] == args["--future"]
|
||||
ctx.list(
|
||||
filter = filterOption,
|
||||
filter = some(filter),
|
||||
states = statesOption,
|
||||
showToday = showBoth or args["--today"],
|
||||
showFuture = showBoth or args["--future"],
|
||||
showHidden = args["--show-hidden"],
|
||||
verbose = ctx.verbose)
|
||||
|
||||
elif args["show"]:
|
||||
|
||||
if args["project-board"]:
|
||||
ctx.showProjectBoard(filterOption)
|
||||
if not args["--show-done"]: filter.exclStates.add(Done)
|
||||
ctx.showProjectBoard(some(filter))
|
||||
discard
|
||||
|
||||
elif args["dupes"]:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const PIT_VERSION* = "4.30.1"
|
||||
const PIT_VERSION* = "4.31.1"
|
||||
|
||||
const USAGE* = """Usage:
|
||||
pit ( new | add) <summary> [<state>] [options]
|
||||
@@ -18,7 +18,7 @@ const USAGE* = """Usage:
|
||||
pit add-binary-property <id> <propName> <propSource> [options]
|
||||
pit get-binary-property <id> <propName> <propDest> [options]
|
||||
pit show dupes [options]
|
||||
pit show project-board [options]
|
||||
pit show project-board [--show-done] [options]
|
||||
pit show <id> [options]
|
||||
pit sync [<syncTarget>...] [options]
|
||||
pit help [options]
|
||||
@@ -33,12 +33,20 @@ Options:
|
||||
a filter to the issues listed, only allowing those
|
||||
which have all of the given properties.
|
||||
|
||||
If a propert name is provided without a value, this
|
||||
will allow all issues which have any value defined
|
||||
for the named property.
|
||||
|
||||
-P, --excl-properties <props>
|
||||
When used with the list command, exclude issues
|
||||
that contain properties with the given value. This
|
||||
parameter is formatted the same as the --properties
|
||||
parameter: "key:val;key:val"
|
||||
|
||||
If no value is provided for a property, this will
|
||||
filter out all issues with *any* value for that
|
||||
property.
|
||||
|
||||
-c, --context <ctx> Shorthand for '-p context:<ctx>'
|
||||
|
||||
-C, --excl-context <ctx> Don't show issues from the given context(s).
|
||||
@@ -71,8 +79,6 @@ Options:
|
||||
|
||||
-q, --quiet Suppress verbose output.
|
||||
|
||||
-y, --yes Automatically answer "yes" to any prompts.
|
||||
|
||||
--config <cfgFile> Location of the config file (defaults to $HOME/.pitrc)
|
||||
|
||||
-E, --echo-args Echo arguments (for debug purposes).
|
||||
@@ -88,7 +94,13 @@ Options:
|
||||
only print the changes that would be made, but do
|
||||
not actually make them.
|
||||
|
||||
-s, --silent Suppress all logging and status output.
|
||||
-I, --non-interactive Run in non-interactive mode. Commands that would
|
||||
normally prompt for user input will instead use
|
||||
default values or fail if required input is not
|
||||
provided via command-line options.
|
||||
|
||||
-s, --silent Suppress all logging and status output and run in
|
||||
non-interactive mode (implies --non-interactive).
|
||||
"""
|
||||
|
||||
const ONLINE_HELP* = """Issue States:
|
||||
@@ -235,4 +247,4 @@ Issue Properties:
|
||||
|
||||
If present, expected to be a comma-delimited list of text tags. The -g
|
||||
option is a short-hand for '-p tags:<tags-value>'.
|
||||
"""
|
||||
"""
|
||||
|
||||
@@ -173,7 +173,6 @@ proc list*(
|
||||
states: Option[seq[IssueState]],
|
||||
showToday = false,
|
||||
showFuture = false,
|
||||
showHidden = false,
|
||||
verbose: bool) =
|
||||
|
||||
if states.isSome:
|
||||
@@ -219,10 +218,7 @@ proc list*(
|
||||
|
||||
for s in [Current, TodoToday, Pending]:
|
||||
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
|
||||
let visibleIssues = ctx.issues[s].filterIt(
|
||||
showHidden or
|
||||
not (it.hasProp("hide-until") and
|
||||
it.getDateTime("hide-until") > getTime().local))
|
||||
let visibleIssues = ctx.issues[s]
|
||||
|
||||
if isatty(stdout):
|
||||
stdout.write ctx.formatSection(visibleIssues, s, indent, verbose)
|
||||
@@ -242,10 +238,7 @@ proc list*(
|
||||
|
||||
for s in futureCategories:
|
||||
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
|
||||
let visibleIssues = ctx.issues[s].filterIt(
|
||||
showHidden or
|
||||
not (it.hasProp("hide-until") and
|
||||
it.getDateTime("hide-until") > getTime().local))
|
||||
let visibleIssues = ctx.issues[s]
|
||||
|
||||
if isatty(stdout):
|
||||
stdout.write ctx.formatSection(visibleIssues, s, indent, verbose)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import std/[json, logging, options, os, strformat, strutils, tables, times,
|
||||
import std/[json, jsonutils, logging, options, os, strformat, strutils, tables, times,
|
||||
unicode]
|
||||
import cliutils, docopt, langutils, uuids, zero_functional
|
||||
|
||||
@@ -30,6 +30,7 @@ type
|
||||
exclStates*: seq[IssueState]
|
||||
hasTags*: seq[string]
|
||||
exclTags*: seq[string]
|
||||
exclHidden*: bool
|
||||
properties*: TableRef[string, string]
|
||||
exclProperties*: TableRef[string, seq[string]]
|
||||
|
||||
@@ -38,6 +39,7 @@ type
|
||||
PitConfig* = ref object
|
||||
tasksDir*: string
|
||||
contexts*: TableRef[string, string]
|
||||
defaultPropertiesByContext*: TableRef[string, seq[string]]
|
||||
autoSync*: bool
|
||||
syncTargets*: seq[JsonNode]
|
||||
cfg*: CombinedConfig
|
||||
@@ -55,6 +57,7 @@ type
|
||||
isFromCompletion*: bool
|
||||
|
||||
|
||||
const MATCH_ANY* = "<match-any>"
|
||||
const DONE_FOLDER_FORMAT* = "yyyy-MM"
|
||||
const ISO8601_MS = "yyyy-MM-dd'T'HH:mm:ss'.'fffzzz"
|
||||
|
||||
@@ -150,6 +153,7 @@ proc initFilter*(): IssueFilter =
|
||||
summaryMatch: none(Regex),
|
||||
inclStates: @[],
|
||||
exclStates: @[],
|
||||
exclHidden: true,
|
||||
hasTags: @[],
|
||||
exclTags: @[],
|
||||
properties: newTable[string, string](),
|
||||
@@ -183,6 +187,10 @@ proc stateFilter*(states: seq[IssueState]): IssueFilter =
|
||||
result = initFilter()
|
||||
result.inclStates = states
|
||||
|
||||
proc showHiddenFilter*(): IssueFilter =
|
||||
result = initFilter()
|
||||
result.exclHidden = false
|
||||
|
||||
proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] =
|
||||
result = newTable[string, seq[Issue]]()
|
||||
for i in issues:
|
||||
@@ -415,11 +423,23 @@ proc nextRecurrence*(tasksDir: string, rec: Recurrence, defaultIssue: Issue): Is
|
||||
proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
|
||||
var f: seq[Issue] = issues
|
||||
|
||||
if filter.exclHidden:
|
||||
let now = getTime().local
|
||||
f = f --> filter(
|
||||
not it.hasProp("hide-until") or
|
||||
it.getDateTime("hide-until") <= now)
|
||||
|
||||
for k,v in filter.properties:
|
||||
f = f --> filter(it.hasProp(k) and it[k] == v)
|
||||
if v == MATCH_ANY:
|
||||
f = f --> filter(it.hasProp(k))
|
||||
else:
|
||||
f = f --> filter(it.hasProp(k) and it[k] == v)
|
||||
|
||||
for k,v in filter.exclProperties:
|
||||
f = f --> filter(not (it.hasProp(k) and v.contains(it[k])))
|
||||
if v.len == 0:
|
||||
f = f --> filter(not (it.hasProp(k)))
|
||||
else:
|
||||
f = f --> filter(not (it.hasProp(k) and v.contains(it[k])))
|
||||
|
||||
if filter.completedRange.isSome:
|
||||
let range = filter.completedRange.get
|
||||
@@ -486,12 +506,18 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
|
||||
cfg: cfg,
|
||||
autoSync: parseBool(cfg.getVal("auto-sync", "false")),
|
||||
contexts: newTable[string,string](),
|
||||
defaultPropertiesByContext: newTable[string, seq[string]](),
|
||||
tasksDir: cfg.getVal("tasks-dir", ""),
|
||||
syncTargets: cfg.getJson("sync-targets", newJArray()).getElems)
|
||||
|
||||
for k, v in cfg.getJson("contexts", newJObject()):
|
||||
result.contexts[k] = v.getStr()
|
||||
|
||||
for k, v in cfg.getJson("defaultPropertiesByContext", newJObject()):
|
||||
result.defaultPropertiesByContext[k] = v.getElems() -->
|
||||
map(it.getStr("").strip())
|
||||
.filter(not it.isEmptyOrWhitespace)
|
||||
|
||||
if isEmptyOrWhitespace(result.tasksDir):
|
||||
raise newException(Exception, "no tasks directory configured")
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ from std/sequtils import repeat, toSeq
|
||||
import cliutils, uuids, zero_functional
|
||||
import ./[formatting, libpit]
|
||||
|
||||
const NO_PROJECT* = "<no-project>"
|
||||
const NO_MILESTONE* = "<no-project>"
|
||||
const NO_CONTEXT* = "<no-project>"
|
||||
|
||||
type
|
||||
ProjectCfg* = ref object of RootObj
|
||||
name: string
|
||||
@@ -62,15 +66,18 @@ proc buildDb*(ctx: CliContext, cfg: ProjectsConfiguration): ProjectsDatabase =
|
||||
# Now populate the database with issues
|
||||
for (state, issues) in pairs(ctx.issues):
|
||||
for issue in issues:
|
||||
if not issue.hasProp("project") or
|
||||
not issue.hasProp("milestone"):
|
||||
continue
|
||||
|
||||
let projectName = issue["project"]
|
||||
let milestone = issue["milestone"]
|
||||
let projectName =
|
||||
if issue.hasProp("project"): issue["project"]
|
||||
else: NO_PROJECT
|
||||
|
||||
let milestone =
|
||||
if issue.hasProp("milestone"): issue["milestone"]
|
||||
else: NO_MILESTONE
|
||||
|
||||
let context =
|
||||
if issue.hasProp("context"): issue["context"]
|
||||
else: "<no-context>"
|
||||
else: NO_CONTEXT
|
||||
|
||||
# Make sure we have entries for this context and project
|
||||
if not result.hasKey(context): result[context] = @[]
|
||||
@@ -128,15 +135,18 @@ proc listProjects*(ctx: CliContext, filter = none[IssueFilter]()) =
|
||||
|
||||
for (state, issues) in pairs(ctx.issues):
|
||||
for issue in issues:
|
||||
if issue.hasProp("project"):
|
||||
let context =
|
||||
if issue.hasProp("context"): issue["context"]
|
||||
else: "<no-context>"
|
||||
let context =
|
||||
if issue.hasProp("context"): issue["context"]
|
||||
else: NO_CONTEXT
|
||||
|
||||
if not projectsByContext.hasKey(context):
|
||||
projectsByContext[context] = newCountTable[string]()
|
||||
let projectName =
|
||||
if issue.hasProp("project"): issue["project"]
|
||||
else: NO_PROJECT
|
||||
|
||||
projectsByContext[context].inc(issue["project"])
|
||||
if not projectsByContext.hasKey(context):
|
||||
projectsByContext[context] = newCountTable[string]()
|
||||
|
||||
projectsByContext[context].inc(projectName)
|
||||
|
||||
for (context, projects) in pairs(projectsByContext):
|
||||
|
||||
|
||||
Reference in New Issue
Block a user