Compare commits
9 Commits
8b0c751344
...
4.31.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ceca9b009 | |||
| de07665a8b | |||
| 5dd7a15bf4 | |||
| 6ac068fe75 | |||
| 759d00e2f8 | |||
| bc37640f2e | |||
| 3ee5bdf8fd | |||
| 85d561c8a5 | |||
| 1064de3e1b |
@@ -1,2 +1,2 @@
|
||||
[tools]
|
||||
nim = "2.2.0"
|
||||
nim = "2.2.6"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Package
|
||||
|
||||
version = "4.29.0"
|
||||
version = "4.31.0"
|
||||
author = "Jonathan Bernard"
|
||||
description = "Personal issue tracker."
|
||||
license = "MIT"
|
||||
@@ -13,14 +13,13 @@ bin = @["pit"]
|
||||
requires @[
|
||||
"nim >= 1.4.0",
|
||||
"docopt >= 0.7.1",
|
||||
"jester >= 0.6.0",
|
||||
"uuids >= 0.1.10",
|
||||
"zero_functional"
|
||||
]
|
||||
|
||||
# Dependencies from git.jdb-software.com/jdb/nim-packages
|
||||
requires @[
|
||||
"cliutils >= 0.9.1",
|
||||
"cliutils >= 0.10.2",
|
||||
"langutils >= 0.4.0",
|
||||
"timeutils >= 0.5.4",
|
||||
"data_uri > 1.0.0",
|
||||
|
||||
263
src/pit.nim
263
src/pit.nim
@@ -1,12 +1,13 @@
|
||||
## 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, sync_pbm_vsb]
|
||||
import pit/[cliconstants, formatting, libpit, projects, sync_pbm_vsb]
|
||||
|
||||
export formatting, libpit
|
||||
|
||||
@@ -31,12 +32,95 @@ proc parseExclPropertiesOption(propsOpt: string): TableRef[string, seq[string]]
|
||||
if result.hasKey(pair[0]): result[pair[0]].add(val)
|
||||
else: result[pair[0]] = @[val]
|
||||
|
||||
|
||||
proc reorder(ctx: CliContext, state: IssueState) =
|
||||
|
||||
# load the issues to make sure the order file contains all issues in the state.
|
||||
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)
|
||||
@@ -87,6 +171,10 @@ when isMainModule:
|
||||
var tagsOption = none(seq[string])
|
||||
var exclTagsOption = none(seq[string])
|
||||
|
||||
let filter = initFilter()
|
||||
var filterOption = none(IssueFilter)
|
||||
|
||||
|
||||
if args["--properties"] or args["--context"]:
|
||||
|
||||
var props =
|
||||
@@ -116,29 +204,57 @@ when isMainModule:
|
||||
if args["--excl-tags"]: exclTagsOption =
|
||||
some(($args["--excl-tags"]).split(",").mapIt(it.strip))
|
||||
|
||||
# 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)
|
||||
|
||||
# Finally, if the "context" is "all", don't filter on context
|
||||
if filter.properties.hasKey("context") and
|
||||
filter.properties["context"] == "all":
|
||||
|
||||
filter.properties.del("context")
|
||||
filter.exclProperties.del("context")
|
||||
|
||||
## 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>"]))
|
||||
@@ -224,7 +340,6 @@ when isMainModule:
|
||||
updatedIssues.add(nextIssue)
|
||||
stdout.writeLine formatIssue(nextIssue)
|
||||
|
||||
|
||||
issue.changeState(ctx.cfg.tasksDir, targetState)
|
||||
updatedIssues.add(issue)
|
||||
|
||||
@@ -266,7 +381,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
|
||||
@@ -276,55 +391,16 @@ when isMainModule:
|
||||
|
||||
elif args["list"]:
|
||||
|
||||
let filter = initFilter()
|
||||
var filterOption = none(IssueFilter)
|
||||
|
||||
# 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)
|
||||
|
||||
# Finally, if the "context" is "all", don't filter on context
|
||||
if filter.properties.hasKey("context") and
|
||||
filter.properties["context"] == "all":
|
||||
|
||||
filter.properties.del("context")
|
||||
filter.exclProperties.del("context")
|
||||
|
||||
var listContexts = false
|
||||
var listTags = false
|
||||
var listProjects = false
|
||||
var listMilestones = false
|
||||
var statesOption = none(seq[IssueState])
|
||||
var issueIdsOption = none(seq[string])
|
||||
|
||||
if args["contexts"]: listContexts = true
|
||||
elif args["projects"]: listProjects = true
|
||||
elif args["milestones"]: listMilestones = true
|
||||
elif args["tags"]: listTags = true
|
||||
elif args["<stateOrId>"]:
|
||||
try:
|
||||
@@ -372,6 +448,12 @@ when isMainModule:
|
||||
let issue = ctx.cfg.tasksDir.loadIssueById(issueId)
|
||||
stdout.writeLine formatIssue(issue)
|
||||
|
||||
# List projects
|
||||
elif listProjects: ctx.listProjects(filterOption)
|
||||
|
||||
# List milestones
|
||||
elif listMilestones: ctx.listMilestones(filterOption)
|
||||
|
||||
# List all issues
|
||||
else:
|
||||
trace "listing all issues"
|
||||
@@ -384,6 +466,32 @@ when isMainModule:
|
||||
showHidden = args["--show-hidden"],
|
||||
verbose = ctx.verbose)
|
||||
|
||||
elif args["show"]:
|
||||
|
||||
if args["project-board"]:
|
||||
ctx.showProjectBoard(filterOption)
|
||||
discard
|
||||
|
||||
elif args["dupes"]:
|
||||
ctx.loadAllIssues()
|
||||
|
||||
var idsToPaths = newTable[string, var seq[string]]()
|
||||
for (state, issues) in pairs(ctx.issues):
|
||||
for issue in issues:
|
||||
let issueId = $issue.id
|
||||
|
||||
if idsToPaths.hasKey(issueId): idsToPaths[issueId].add(issue.filepath)
|
||||
else: idsToPaths[issueId] = @[issue.filepath]
|
||||
|
||||
for (issueId, issuePaths) in pairs(idsToPaths):
|
||||
if issuePaths.len < 2: continue
|
||||
stdout.writeLine(issueId & ":\p " & issuePaths.join("\p ") & "\p\p")
|
||||
|
||||
else: # list specific Issues
|
||||
for issueId in args["<id>"].mapIt($it):
|
||||
let issue = ctx.cfg.tasksDir.loadIssueById(issueId)
|
||||
stdout.writeLine formatIssue(issue)
|
||||
|
||||
elif args["add-binary-property"]:
|
||||
let issue = ctx.cfg.tasksDir.loadIssueById($(args["<id>"]))
|
||||
|
||||
@@ -412,21 +520,6 @@ when isMainModule:
|
||||
try: write(propOut, decodeDataUri(issue[$(args["<propName>"])]))
|
||||
finally: close(propOut)
|
||||
|
||||
elif args["show-dupes"]:
|
||||
ctx.loadAllIssues()
|
||||
|
||||
var idsToPaths = newTable[string, var seq[string]]()
|
||||
for (state, issues) in pairs(ctx.issues):
|
||||
for issue in issues:
|
||||
let issueId = $issue.id
|
||||
|
||||
if idsToPaths.hasKey(issueId): idsToPaths[issueId].add(issue.filepath)
|
||||
else: idsToPaths[issueId] = @[issue.filepath]
|
||||
|
||||
for (issueId, issuePaths) in pairs(idsToPaths):
|
||||
if issuePaths.len < 2: continue
|
||||
stdout.writeLine(issueId & ":\p " & issuePaths.join("\p ") & "\p\p")
|
||||
|
||||
elif args["sync"]:
|
||||
if ctx.cfg.syncTargets.len == 0:
|
||||
info "No sync targets configured"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
const PIT_VERSION* = "4.29.0"
|
||||
const PIT_VERSION* = "4.31.0"
|
||||
|
||||
const USAGE* = """Usage:
|
||||
pit ( new | add) <summary> [<state>] [options]
|
||||
pit list contexts [options]
|
||||
pit list projects [options]
|
||||
pit list milestones [options]
|
||||
pit list tags [options]
|
||||
pit list [<stateOrId>...] [options]
|
||||
pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options]
|
||||
@@ -15,10 +17,13 @@ const USAGE* = """Usage:
|
||||
pit ( delete | rm ) <id>... [options]
|
||||
pit add-binary-property <id> <propName> <propSource> [options]
|
||||
pit get-binary-property <id> <propName> <propDest> [options]
|
||||
pit show-dupes
|
||||
pit show dupes [options]
|
||||
pit show project-board [options]
|
||||
pit show <id> [options]
|
||||
pit sync [<syncTarget>...] [options]
|
||||
pit help [options]
|
||||
|
||||
|
||||
Options:
|
||||
|
||||
-h, --help Print this usage and help information.
|
||||
@@ -66,8 +71,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).
|
||||
@@ -83,7 +86,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:
|
||||
@@ -144,6 +153,38 @@ Issue Properties:
|
||||
|
||||
hide-until: 2024-01-01T13:45:00-05:00
|
||||
|
||||
priority
|
||||
|
||||
Allows setting a priority/priority level. This is used in the project
|
||||
management view to automatically order issues being displayed. Valid
|
||||
values, in order from most to least important, are:
|
||||
|
||||
- essential Intended for issues that must be done. Failure to
|
||||
complete these issues would result in failure of the
|
||||
project.
|
||||
|
||||
- vital Intended for issues that are vital to the success of the
|
||||
project, but not absolutely essential. Failure to complete
|
||||
these issues may not result in failure of the project but
|
||||
would seriously impact it's value or viability.
|
||||
|
||||
- important Intended for issues that are important to the project,
|
||||
but not vital. These should be completed, but delay is
|
||||
acceptable.
|
||||
|
||||
- optional Intended for issues that are worth doing but can be deferred
|
||||
or skipped if necessary.
|
||||
|
||||
priority: essential
|
||||
|
||||
milestone
|
||||
|
||||
Allows grouping issues according to a milestone name. Milestones are
|
||||
available as subsets of projects. The 'list milestones' command will show
|
||||
all values of 'milestone' set in existing issues for a given project.
|
||||
|
||||
milestone: Phase 1
|
||||
|
||||
pending
|
||||
|
||||
When an issue is blocked by a third party, this property can be used to
|
||||
@@ -152,6 +193,13 @@ Issue Properties:
|
||||
|
||||
pending: Results of WCAG analysis.
|
||||
|
||||
project
|
||||
|
||||
Allows grouping issues according to a project name. The 'list projects'
|
||||
command will show all values of 'project' set in existing issues.
|
||||
|
||||
project: Website Redesign
|
||||
|
||||
recurrence
|
||||
|
||||
When an issue is moved to the "done" state, if the issue has a valid
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import std/[options, sequtils, wordwrap, tables, terminal, times, unicode, wordwrap]
|
||||
import std/[options, sequtils, tables, terminal, times, unicode, wordwrap]
|
||||
import cliutils, uuids
|
||||
import std/strutils except alignLeft, capitalize, strip, toLower, toUpper
|
||||
import ./libpit
|
||||
|
||||
proc adjustedTerminalWidth(): int = min(terminalWidth(), 80)
|
||||
|
||||
proc getIssueContextDisplayName*(ctx: CliContext, context: string): string =
|
||||
if not ctx.contexts.hasKey(context):
|
||||
if context.isEmptyOrWhitespace: return "<default>"
|
||||
else: return context.capitalize()
|
||||
return ctx.contexts[context]
|
||||
|
||||
|
||||
proc formatIssue*(issue: Issue): string =
|
||||
result = ($issue.id).withColor(fgBlack, true) & "\n"&
|
||||
issue.summary.withColor(fgWhite) & "\n"
|
||||
@@ -21,9 +14,22 @@ proc formatIssue*(issue: Issue): string =
|
||||
issue.tags.join(",").withColor(fgGreen, true) & "\n"
|
||||
|
||||
if issue.properties.len > 0:
|
||||
result &= termColor(fgMagenta)
|
||||
for k, v in issue.properties: result &= k & ": " & v & "\n"
|
||||
for k, v in issue.properties:
|
||||
|
||||
if k == "project":
|
||||
result &= "project: ".withColor(fgMagenta) &
|
||||
v.withColor(fgBlue, bright = true) & "\n"
|
||||
|
||||
elif k == "milestone":
|
||||
result &= "milestone: ".withColor(fgMagenta) &
|
||||
v.withColor(fgBlue, bright = true) & "\n"
|
||||
|
||||
elif k == "priority":
|
||||
result &= "priority: ".withColor(fgMagenta) &
|
||||
v.withColor(fgRed, bright = true) & "\n"
|
||||
|
||||
else:
|
||||
result &= termColor(fgMagenta) & k & ": " & v & "\n"
|
||||
|
||||
result &= "--------".withColor(fgBlack, true) & "\n"
|
||||
if not issue.details.isEmptyOrWhitespace:
|
||||
@@ -55,7 +61,8 @@ proc formatSectionIssue*(
|
||||
issue: Issue,
|
||||
width: int = 80,
|
||||
indent = "",
|
||||
verbose = false): string =
|
||||
verbose = false,
|
||||
bold = false): string =
|
||||
|
||||
result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true) & " "
|
||||
|
||||
@@ -68,7 +75,7 @@ proc formatSectionIssue*(
|
||||
.wrapWords(summaryWidth)
|
||||
.splitLines
|
||||
|
||||
result &= summaryLines[0].withColor(fgWhite)
|
||||
result &= summaryLines[0].termFmt(fgWhite, bold=bold, underline=bold)
|
||||
|
||||
for line in summaryLines[1..^1]:
|
||||
result &= "\p" & line.indent(summaryIndentLen)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
|
||||
import nre except toSeq
|
||||
@@ -18,21 +19,26 @@ type
|
||||
Current = "current",
|
||||
TodoToday = "todo-today",
|
||||
Pending = "pending",
|
||||
Done = "done",
|
||||
Todo = "todo"
|
||||
Dormant = "dormant"
|
||||
Done = "done",
|
||||
|
||||
IssueFilter* = ref object
|
||||
completedRange*: Option[tuple[b, e: DateTime]]
|
||||
fullMatch*, summaryMatch*: Option[Regex]
|
||||
inclStates*: seq[IssueState]
|
||||
exclStates*: seq[IssueState]
|
||||
hasTags*: seq[string]
|
||||
exclTags*: seq[string]
|
||||
properties*: TableRef[string, string]
|
||||
exclProperties*: TableRef[string, seq[string]]
|
||||
|
||||
IssuePriority* {.pure.} = enum essential, vital, important, optional
|
||||
|
||||
PitConfig* = ref object
|
||||
tasksDir*: string
|
||||
contexts*: TableRef[string, string]
|
||||
defaultPropertiesByContext*: TableRef[string, seq[string]]
|
||||
autoSync*: bool
|
||||
syncTargets*: seq[JsonNode]
|
||||
cfg*: CombinedConfig
|
||||
@@ -129,6 +135,13 @@ proc getRecurrence*(issue: Issue): Option[Recurrence] =
|
||||
else: weeks(1),
|
||||
cloneId: c[6]))
|
||||
|
||||
proc setPriority*(issue: Issue, priority: IssuePriority) =
|
||||
issue["priority"] = $priority
|
||||
|
||||
proc getPriority*(issue: Issue): IssuePriority =
|
||||
try: result = parseEnum[IssuePriority](issue["priority"].toLowerAscii())
|
||||
except CatchableError: result = IssuePriority.optional
|
||||
|
||||
|
||||
## Issue filtering
|
||||
proc initFilter*(): IssueFilter =
|
||||
@@ -136,6 +149,8 @@ proc initFilter*(): IssueFilter =
|
||||
completedRange: none(tuple[b, e: DateTime]),
|
||||
fullMatch: none(Regex),
|
||||
summaryMatch: none(Regex),
|
||||
inclStates: @[],
|
||||
exclStates: @[],
|
||||
hasTags: @[],
|
||||
exclTags: @[],
|
||||
properties: newTable[string, string](),
|
||||
@@ -165,6 +180,10 @@ proc hasTagsFilter*(tags: seq[string]): IssueFilter =
|
||||
result = initFilter()
|
||||
result.hasTags = tags
|
||||
|
||||
proc stateFilter*(states: seq[IssueState]): IssueFilter =
|
||||
result = initFilter()
|
||||
result.inclStates = states
|
||||
|
||||
proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] =
|
||||
result = newTable[string, seq[Issue]]()
|
||||
for i in issues:
|
||||
@@ -426,6 +445,12 @@ proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
|
||||
let exclTag = exclTagLent
|
||||
f = f --> filter(it.tags.find(exclTag) < 0)
|
||||
|
||||
if filter.inclStates.len > 0:
|
||||
f = f --> filter(filter.inclStates.contains(it.state))
|
||||
|
||||
if filter.exclStates.len > 0:
|
||||
f = f --> filter(not filter.exclStates.contains(it.state))
|
||||
|
||||
return f # not using result because zero_functional doesn't play nice with it
|
||||
|
||||
proc find*(
|
||||
@@ -462,12 +487,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")
|
||||
|
||||
@@ -513,3 +544,9 @@ proc loadAllIssues*(ctx: CliContext) =
|
||||
proc filterIssues*(ctx: CliContext, filter: IssueFilter) =
|
||||
for state, issueList in ctx.issues:
|
||||
ctx.issues[state] = issueList.filter(filter)
|
||||
|
||||
proc getIssueContextDisplayName*(ctx: CliContext, context: string): string =
|
||||
if not ctx.contexts.hasKey(context):
|
||||
if context.isEmptyOrWhitespace: return "<default>"
|
||||
else: return context.capitalize()
|
||||
return ctx.contexts[context]
|
||||
|
||||
368
src/pit/projects.nim
Normal file
368
src/pit/projects.nim
Normal file
@@ -0,0 +1,368 @@
|
||||
import std/[algorithm, json, jsonutils, options, os, sets, strutils, tables,
|
||||
terminal, times, unicode, wordwrap]
|
||||
from std/sequtils import repeat, toSeq
|
||||
import cliutils, uuids, zero_functional
|
||||
import ./[formatting, libpit]
|
||||
|
||||
type
|
||||
ProjectCfg* = ref object of RootObj
|
||||
name: string
|
||||
milestoneOrder*: seq[string]
|
||||
|
||||
Project* = ref object of ProjectCfg
|
||||
milestones*: TableRef[string, seq[Issue]]
|
||||
|
||||
ProjectsConfiguration* = TableRef[string, seq[ProjectCfg]]
|
||||
## ProjectCfgs by context
|
||||
|
||||
ProjectsDatabase* = TableRef[string, seq[Project]]
|
||||
## Projects by context
|
||||
|
||||
|
||||
converter extractConfig(pdb: ProjectsDatabase): ProjectsConfiguration =
|
||||
result = newTable[string, seq[ProjectCfg]]()
|
||||
for (context, projects) in pairs(pdb):
|
||||
result[context] = @[]
|
||||
for project in projects:
|
||||
result[context].add(ProjectCfg(
|
||||
name: project.name,
|
||||
milestoneOrder: project.milestoneOrder))
|
||||
|
||||
|
||||
proc loadProjectsConfiguration*(ctx: CliContext): ProjectsConfiguration =
|
||||
|
||||
let projectsCfgFile = ctx.cfg.tasksDir & "/projects.json"
|
||||
|
||||
if not fileExists(projectsCfgFile):
|
||||
return newTable[string, seq[ProjectCfg]]()
|
||||
else:
|
||||
fromJson[ProjectsConfiguration](result, parseFile(projectsCfgFile))
|
||||
|
||||
|
||||
proc saveProjectsConfiguration*(ctx: CliContext, cfg: ProjectsConfiguration) =
|
||||
let projectsCfgFile = ctx.cfg.tasksDir / "projects.json"
|
||||
writeFile(projectsCfgFile, toJson(cfg).pretty)
|
||||
|
||||
|
||||
proc buildDb*(ctx: CliContext, cfg: ProjectsConfiguration): ProjectsDatabase =
|
||||
result = newTable[string, seq[Project]]()
|
||||
|
||||
# Expand the configuration into the database structure
|
||||
for (context, projectCfgs) in pairs(cfg):
|
||||
result[context] = @[]
|
||||
for projectCfg in projectCfgs:
|
||||
let pcfg = projectCfg
|
||||
let project = Project(
|
||||
name: projectCfg.name,
|
||||
milestoneOrder: projectCfg.milestoneOrder,
|
||||
milestones: newTable[string, seq[Issue]](
|
||||
pcfg.milestoneOrder --> map((it, newSeq[Issue]()))))
|
||||
result[context].add(project)
|
||||
|
||||
# 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 context =
|
||||
if issue.hasProp("context"): issue["context"]
|
||||
else: "<no-context>"
|
||||
|
||||
# Make sure we have entries for this context and project
|
||||
if not result.hasKey(context): result[context] = @[]
|
||||
|
||||
var projectsInContext = result[context]
|
||||
|
||||
if not (projectsInContext --> exists(it.name == projectName)):
|
||||
projectsInContext.add(Project(
|
||||
name: projectName,
|
||||
milestoneOrder: @[],
|
||||
milestones: newTable[string, seq[Issue]]()))
|
||||
|
||||
let projectIdx = projectsInContext --> index(it.name == projectName)
|
||||
var project = projectsInContext[projectIdx]
|
||||
|
||||
# Make sure we have entries for this milestone
|
||||
if not project.milestones.hasKey(milestone):
|
||||
project.milestones[milestone] = @[]
|
||||
|
||||
if not project.milestoneOrder.contains(milestone):
|
||||
project.milestoneOrder.add(milestone)
|
||||
|
||||
project.milestones[milestone].add(issue)
|
||||
|
||||
result[context] = projectsInContext
|
||||
|
||||
ctx.saveProjectsConfiguration(result)
|
||||
|
||||
proc cmp*(a, b: Issue): int =
|
||||
if a.state != b.state:
|
||||
return cmp(ord(a.state), ord(b.state))
|
||||
|
||||
if a.hasProp("priority") or b.hasProp("priority"):
|
||||
if a.getPriority != b.getPriority:
|
||||
return cmp(a.getPriority, b.getPriority) # higher priority first
|
||||
|
||||
if a.hasProp("last-updated") or b.hasProp("last-updated"):
|
||||
var aUpdated = a.getDateTime("last-updated", local(fromUnix(0)))
|
||||
var bUpdated = b.getDateTime("last-updated", local(fromUnix(0)))
|
||||
|
||||
if aUpdated != bUpdated:
|
||||
return cmp(bUpdated, aUpdated) # newer first
|
||||
|
||||
return cmp(a.summary, b.summary)
|
||||
|
||||
|
||||
proc listProjects*(ctx: CliContext, filter = none[IssueFilter]()) =
|
||||
ctx.loadAllIssues()
|
||||
let projectsCfg = ctx.loadProjectsConfiguration()
|
||||
var projectsCfgChanged = false
|
||||
|
||||
if filter.isSome: ctx.filterIssues(filter.get)
|
||||
|
||||
let projectsByContext = newTable[string, CountTableRef[string]]()
|
||||
|
||||
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>"
|
||||
|
||||
if not projectsByContext.hasKey(context):
|
||||
projectsByContext[context] = newCountTable[string]()
|
||||
|
||||
projectsByContext[context].inc(issue["project"])
|
||||
|
||||
for (context, projects) in pairs(projectsByContext):
|
||||
|
||||
stdout.writeLine(withColor(
|
||||
ctx.getIssueContextDisplayName(context) & ":",
|
||||
fgYellow) & termReset)
|
||||
stdout.writeLine("")
|
||||
|
||||
var toList = toHashSet(toSeq(keys(projects)))
|
||||
|
||||
# Loop through the projects in the configured order first
|
||||
if not projectsCfg.hasKey(context):
|
||||
projectsCfg[context] = @[]
|
||||
|
||||
for project in projectsCfg[context]:
|
||||
if project.name in toList:
|
||||
toList.excl(project.name)
|
||||
stdout.writeLine(" " & project.name &
|
||||
" (" & $projects[project.name] & " issues)")
|
||||
|
||||
# Then list any remaining projects not in the configuration, and add them
|
||||
# to the configuration
|
||||
for (projectName, count) in pairs(projects):
|
||||
if projectName in toList:
|
||||
stdout.writeLine(" " & projectName & " (" & $count & " issues)")
|
||||
projectsCfg[context].add(ProjectCfg(name: projectName, milestoneOrder: @[]))
|
||||
projectsCfgChanged = true
|
||||
|
||||
stdout.writeLine("")
|
||||
|
||||
if projectsCfgChanged: ctx.saveProjectsConfiguration(projectsCfg)
|
||||
|
||||
proc listMilestones*(ctx: CliContext, filter = none[IssueFilter]()) =
|
||||
ctx.loadAllIssues()
|
||||
if filter.isSome: ctx.filterIssues(filter.get)
|
||||
|
||||
let projectsCfg = ctx.loadProjectsConfiguration()
|
||||
let projectsDb = ctx.buildDb(projectsCfg)
|
||||
|
||||
var milestones = newCountTable[string]()
|
||||
|
||||
for (context, projects) in pairs(projectsDb):
|
||||
for p in projects:
|
||||
let project = p
|
||||
if values(project.milestones) --> all(it.len == 0):
|
||||
continue
|
||||
|
||||
stdout.writeLine(withColor(project.name, fgBlue, bold = true, bright=true))
|
||||
stdout.writeLine(withColor(
|
||||
"─".repeat(runeLen(stripAnsi(project.name))),
|
||||
fgBlue, bold = true))
|
||||
|
||||
for milestone in project.milestoneOrder:
|
||||
if project.milestones.hasKey(milestone) and
|
||||
project.milestones[milestone].len > 0:
|
||||
let issueCount = project.milestones[milestone].len
|
||||
stdout.writeLine(" " & milestone & " (" & $issueCount & " issues)")
|
||||
|
||||
stdout.writeLine("")
|
||||
|
||||
proc formatProjectIssue(
|
||||
ctx: CliContext,
|
||||
issue: Issue,
|
||||
width: int): seq[string] =
|
||||
|
||||
var firstLine = ""
|
||||
if issue.state == IssueState.Done:
|
||||
firstLine &= withColor(" ✔ ", fgBlack, bold=true, bright=true)
|
||||
else:
|
||||
case issue.getPriority
|
||||
of IssuePriority.essential:
|
||||
firstLine &= withColor("❶ ", fgRed, bold=true, bright=true)
|
||||
of IssuePriority.vital:
|
||||
firstLine &= withColor("❷ ", fgYellow, bold=true, bright=true)
|
||||
of IssuePriority.important:
|
||||
firstLine &= withColor("❸ ", fgBlue, bold=true, bright=true)
|
||||
of IssuePriority.optional:
|
||||
firstLine &= withColor("❹ ", fgBlack, bold=false, bright=true)
|
||||
|
||||
let summaryText = formatSectionIssue(issue, width - 3,
|
||||
bold = [Current, TodoToday].contains(issue.state)).splitLines
|
||||
firstLine &= summaryText[0]
|
||||
|
||||
if issue.state == IssueState.Done:
|
||||
firstLine = withColor(stripAnsi(firstLine), fgBlack, bright=true)
|
||||
|
||||
result.add(firstLine)
|
||||
result.add(summaryText[1 .. ^1] --> map(" " & it))
|
||||
|
||||
if issue.state == IssueState.Done:
|
||||
let origLines = result
|
||||
result = origLines --> map(withColor(stripAnsi(it), fgBlack, bright=true))
|
||||
|
||||
proc formatParentIssue*(
|
||||
ctx: CliContext,
|
||||
parentIssue: Issue,
|
||||
children: seq[Issue],
|
||||
width: int): seq[string] =
|
||||
|
||||
result.add(ctx.formatProjectIssue(parentIssue, width))
|
||||
|
||||
for child in sorted(children, cmp):
|
||||
let childLines = ctx.formatProjectIssue(child, width - 3)
|
||||
result.add(childLines --> map(withColor(" │ ", fgBlack, bright=true) & it))
|
||||
|
||||
result.add("")
|
||||
|
||||
|
||||
proc formatMilestone*(
|
||||
ctx: CliContext,
|
||||
milestone: string,
|
||||
issues: seq[Issue],
|
||||
availWidth: int): seq[string] =
|
||||
|
||||
result = @[""]
|
||||
result.add(withColor(milestone, fgWhite, bold=true))
|
||||
result.add(withColor("─".repeat(availWidth), fgWhite))
|
||||
|
||||
var parentsToChildren = issues -->
|
||||
filter(it.hasProp("parent")).group(it["parent"])
|
||||
|
||||
var issuesToFormat = sorted(issues, cmp) -->
|
||||
filter(not it.hasProp("parent"))
|
||||
|
||||
for issue in issuesToFormat:
|
||||
if parentsToChildren.hasKey($issue.id):
|
||||
result.add(
|
||||
ctx.formatParentIssue(issue, parentsToChildren[$issue.id], availWidth))
|
||||
else:
|
||||
result.add(ctx.formatProjectIssue(issue, availWidth))
|
||||
|
||||
proc findShortestColumn(columns: seq[seq[string]]): int =
|
||||
var shortestIdx = 0
|
||||
var shortestLen = columns[0].len
|
||||
|
||||
for i in 1 ..< columns.len:
|
||||
if columns[i].len < shortestLen:
|
||||
shortestLen = columns[i].len
|
||||
shortestIdx = i
|
||||
|
||||
return shortestIdx
|
||||
|
||||
|
||||
proc joinColumns(columns: seq[seq[string]], columnWidth: int): seq[string] =
|
||||
let maxLines = columns --> map(it.len).max()
|
||||
|
||||
for lineNo in 0 ..< maxLines:
|
||||
var newLine = ""
|
||||
for col in columns:
|
||||
if lineNo < col.len:
|
||||
let lineLen = runeLen(stripAnsi(col[lineNo]))
|
||||
newLine &= col[lineNo] & " ".repeat(max(0, columnWidth - lineLen) + 2)
|
||||
else:
|
||||
newLine &= " ".repeat(columnWidth + 2)
|
||||
|
||||
result.add(newLine)
|
||||
|
||||
proc showProject*(ctx: CliContext, project: Project) =
|
||||
|
||||
let fullWidth = terminalWidth() - 1
|
||||
let columnWidth = 80
|
||||
let numColumns = (fullWidth - 4) div (columnWidth + 2)
|
||||
|
||||
stdout.writeLine("")
|
||||
stdout.writeLine(withColor(
|
||||
"┌" & "─".repeat(project.name.len + 2) &
|
||||
"┬" & "─".repeat(fullWidth - project.name.len - 4) & "┐",
|
||||
fgBlue, bold=true))
|
||||
stdout.writeLine(
|
||||
withColor("│ ", fgBlue, bold=true) &
|
||||
withColor(project.name, fgBlue, bold=true, bright=true) &
|
||||
withColor(" │" & " ".repeat(fullWidth - project.name.len - 4) & "│", fgBlue, bold=true))
|
||||
stdout.writeLine(withColor(
|
||||
"├" & "─".repeat(project.name.len + 2) &
|
||||
"┘" & " ".repeat(fullWidth - project.name.len - 4) & "│",
|
||||
fgBlue, bold=true))
|
||||
|
||||
let milestoneTexts: seq[seq[string]] = project.milestoneOrder -->
|
||||
filter(project.milestones.hasKey(it) and project.milestones[it].len > 0).
|
||||
map(ctx.formatMilestone(it, project.milestones[it], columnWidth))
|
||||
|
||||
var columns: seq[seq[string]] = repeat(newSeq[string](), numColumns)
|
||||
|
||||
for milestoneText in milestoneTexts:
|
||||
let shortestColumnIdx = findShortestColumn(columns)
|
||||
columns[shortestColumnIdx].add(milestoneText)
|
||||
|
||||
let joinedLines = joinColumns(columns, columnWidth)
|
||||
|
||||
for line in joinedLines:
|
||||
let padLen = fullWidth - runeLen(stripAnsi(line)) - 3
|
||||
stdout.writeLine(
|
||||
withColor("│ ", fgBlue) &
|
||||
line &
|
||||
" ".repeat(padLen) &
|
||||
withColor(" │", fgBlue))
|
||||
|
||||
stdout.writeLine(withColor(
|
||||
"└" & "─".repeat(terminalWidth() - 2) & "┘",
|
||||
fgBlue, bold=true))
|
||||
|
||||
|
||||
proc showProjectBoard*(ctx: CliContext, filter = none[IssueFilter]()) =
|
||||
ctx.loadAllIssues()
|
||||
if filter.isSome: ctx.filterIssues(filter.get)
|
||||
|
||||
let projectsCfg = ctx.loadProjectsConfiguration()
|
||||
let projectsDb = ctx.buildDb(projectsCfg)
|
||||
|
||||
var contextsAndProjects: seq[(string, seq[Project])] = @[]
|
||||
|
||||
for (context, pjs) in pairs(projectsDb):
|
||||
let projects = pjs
|
||||
let issues: seq[Issue] = projects --> map(toSeq(values(it.milestones))).flatten().flatten()
|
||||
if issues.len > 0:
|
||||
contextsAndProjects.add((context, projects))
|
||||
|
||||
for (context, projects) in contextsAndProjects:
|
||||
if contextsAndProjects.len > 1:
|
||||
stdout.writeLine("")
|
||||
stdout.writeLine(withColor(
|
||||
ctx.getIssueContextDisplayName(context) & ":",
|
||||
fgYellow, bold=true))
|
||||
stdout.writeLine("")
|
||||
|
||||
for p in projects:
|
||||
let project = p
|
||||
if (values(project.milestones) --> exists(it.len > 0)):
|
||||
ctx.showProject(project)
|
||||
@@ -19,8 +19,11 @@ type
|
||||
lastUpdatedAt*: DateTime
|
||||
archivedAt*: Option[DateTime]
|
||||
tags*: seq[string]
|
||||
context*: Option[string]
|
||||
parent*: Option[string]
|
||||
priority*: Option[string]
|
||||
project*: Option[string]
|
||||
properties*: JsonNode
|
||||
milestone*: Option[string]
|
||||
hideUntil*: Option[DateTime]
|
||||
|
||||
@@ -32,7 +35,7 @@ proc fromJsonHook(dt: var DateTime, n: JsonNode): void =
|
||||
|
||||
|
||||
#func `%`*(p: Project): JsonNode = toJson(p)
|
||||
func `%`*(t: ServerTask): JsonNode = toJson(t)
|
||||
proc `%`*(t: ServerTask): JsonNode = toJson(t)
|
||||
|
||||
|
||||
proc toServerTask(i: Issue): ServerTask =
|
||||
@@ -42,9 +45,18 @@ proc toServerTask(i: Issue): ServerTask =
|
||||
details: i.details,
|
||||
state: $i.state,
|
||||
tags: i.tags,
|
||||
properties: %i.properties,
|
||||
|
||||
context:
|
||||
if i.hasProp("context"): some(i["context"])
|
||||
else: none[string](),
|
||||
|
||||
lastUpdatedAt:
|
||||
if i.hasProp("last-updated"): parseIso8601(i["last-updated"])
|
||||
else: now(),
|
||||
parent:
|
||||
if i.hasProp("parent"): some(i["parent"])
|
||||
else: none[string](),
|
||||
priority:
|
||||
if i.hasProp("priority"): some(i["priority"])
|
||||
else: none[string](),
|
||||
@@ -76,7 +88,7 @@ proc initSyncContext*(pit: PitConfig, syncConfig: JsonNode): PbmVsbSyncContext =
|
||||
|
||||
proc fetchServerTasks*(ctx: PbmVsbSyncContext): seq[ServerTask] =
|
||||
result = newSeq[ServerTask]()
|
||||
let url = ctx.apiBaseUrl & "/tasks"
|
||||
let url = ctx.apiBaseUrl & "/tasks?context=" & ctx.issueContext
|
||||
|
||||
debug "Fetching tasks from server:\n\t" & url
|
||||
let resp = ctx.http.get(url)
|
||||
|
||||
Reference in New Issue
Block a user