Compare commits

..

11 Commits

Author SHA1 Message Date
89c924bb72 Only emit ANSI escape codes when stdout is a TTY. 2025-12-01 18:07:03 -06:00
3d1dc7512a Allow including or excluding properties by name only. 2025-12-01 14:04:19 -06:00
1d18be9d1b Include issues without project or milestone on boards.
In order to help organize issues, show issues on boards even if they
don't have an assigned project or milestone.

Refactor the issue hiding feature (using the `hide-until` property) to
be an option to IssueFilter rather than a separate, special-case. This
means that the CLI always filters by default.

Hide issues in the Done state on project boards unless the new
`--show-done` arg is passed.
2025-12-01 13:50:13 -06:00
6ceca9b009 Make add interactive and allow defining default properties that should be prompted. 2025-11-24 10:47:22 -06:00
de07665a8b Project boards: only show contexts with selected issues. 2025-11-19 19:00:01 -06:00
5dd7a15bf4 Add support for a project view like virtual-status-board.probatem.com 2025-11-19 17:12:14 -06:00
6ac068fe75 Move getIssueContextDisplayName to libpit (out of formatting module). 2025-11-19 17:11:21 -06:00
759d00e2f8 Move filter logic earlier in CLI processing, support state filtering. 2025-11-19 17:10:14 -06:00
bc37640f2e Bump Nim version to 2.2.6. 2025-11-19 17:08:01 -06:00
3ee5bdf8fd Support for syncing all properties (including context) to Probatem's Virtual Status Board. 2025-08-02 22:44:04 -05:00
85d561c8a5 Update Nim pinned version, drop jester dependency (pit_api currently not supported). 2025-05-06 14:45:08 -05:00
8 changed files with 733 additions and 130 deletions

View File

@@ -1,2 +1,2 @@
[tools]
nim = "2.2.0"
nim = "2.2.6"

View File

@@ -1,6 +1,6 @@
# Package
version = "4.29.1"
version = "4.31.2"
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",

View File

@@ -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
@@ -18,18 +19,17 @@ 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) =
@@ -37,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)
@@ -87,6 +170,8 @@ when isMainModule:
var tagsOption = none(seq[string])
var exclTagsOption = none(seq[string])
let filter = initFilter()
if args["--properties"] or args["--context"]:
var props =
@@ -116,29 +201,51 @@ 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
# Add property exclusions (if given)
if exclPropsOption.isSome:
filter.exclProperties = exclPropsOption.get
# If they supplied text matches, add that to the filter.
if args["--match"]:
filter.summaryMatch = some(re("(?i)" & $args["--match"]))
if args["--match-all"]:
filter.fullMatch = some(re("(?i)" & $args["--match-all"]))
# 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
if tagsOption.isSome:
filter.hasTags = tagsOption.get
if exclTagsOption.isSome:
filter.exclTags = exclTagsOption.get
if args["--today"]:
filter.inclStates.add(@[Current, TodoToday, Pending])
if args["--future"]:
filter.inclStates.add(@[Pending, Todo])
if args["--show-hidden"]:
filter.exclHidden = false
# 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 +331,6 @@ when isMainModule:
updatedIssues.add(nextIssue)
stdout.writeLine formatIssue(nextIssue)
issue.changeState(ctx.cfg.tasksDir, targetState)
updatedIssues.add(issue)
@@ -266,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
@@ -276,55 +382,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:
@@ -357,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:
@@ -372,18 +439,50 @@ when isMainModule:
let issue = ctx.cfg.tasksDir.loadIssueById(issueId)
stdout.writeLine formatIssue(issue)
# List projects
elif listProjects: ctx.listProjects(some(filter))
# List milestones
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"]:
if not args["--show-done"]: filter.exclStates.add(Done)
ctx.showProjectBoard(some(filter))
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 +511,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"

View File

@@ -1,8 +1,10 @@
const PIT_VERSION* = "4.29.1"
const PIT_VERSION* = "4.31.2"
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 [--show-done] [options]
pit show <id> [options]
pit sync [<syncTarget>...] [options]
pit help [options]
Options:
-h, --help Print this usage and help information.
@@ -28,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).
@@ -66,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).
@@ -83,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:
@@ -144,6 +161,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 +201,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

View File

@@ -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)
@@ -166,7 +173,6 @@ proc list*(
states: Option[seq[IssueState]],
showToday = false,
showFuture = false,
showHidden = false,
verbose: bool) =
if states.isSome:
@@ -212,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)
@@ -235,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)

View File

@@ -1,4 +1,5 @@
import std/[json, logging, options, os, strformat, strutils, tables, times]
import std/[json, logging, options, os, strformat, strutils, tables, times,
unicode]
import cliutils, docopt, langutils, uuids, zero_functional
import nre except toSeq
@@ -18,21 +19,27 @@ 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]
exclHidden*: bool
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
@@ -50,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"
@@ -129,6 +137,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 +151,9 @@ proc initFilter*(): IssueFilter =
completedRange: none(tuple[b, e: DateTime]),
fullMatch: none(Regex),
summaryMatch: none(Regex),
inclStates: @[],
exclStates: @[],
exclHidden: true,
hasTags: @[],
exclTags: @[],
properties: newTable[string, string](),
@@ -165,6 +183,14 @@ proc hasTagsFilter*(tags: seq[string]): IssueFilter =
result = initFilter()
result.hasTags = tags
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:
@@ -397,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
@@ -426,6 +464,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 +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")
@@ -513,3 +563,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]

400
src/pit/projects.nim Normal file
View File

@@ -0,0 +1,400 @@
import std/[algorithm, json, jsonutils, options, os, sets, strutils, tables,
terminal, times, unicode]
from std/sequtils import repeat, toSeq
import cliutils, uuids, zero_functional
import ./[formatting, libpit]
const NO_PROJECT* = "∅ No Project"
const NO_MILESTONE* = "∅ No Milestone"
const NO_CONTEXT* = "<no-context>"
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:
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
# 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
var linesToPrint = newSeq[string]()
if filter.isSome: ctx.filterIssues(filter.get)
let projectsByContext = newTable[string, CountTableRef[string]]()
for (state, issues) in pairs(ctx.issues):
for issue in issues:
let context =
if issue.hasProp("context"): issue["context"]
else: NO_CONTEXT
let projectName =
if issue.hasProp("project"): issue["project"]
else: NO_PROJECT
if not projectsByContext.hasKey(context):
projectsByContext[context] = newCountTable[string]()
projectsByContext[context].inc(projectName)
for (context, projects) in pairs(projectsByContext):
linesToPrint.add(withColor(
ctx.getIssueContextDisplayName(context) & ":",
fgYellow) & termReset)
linesToPrint.add("")
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)
linesToPrint.add(" " & 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:
linesToPrint.add(" " & projectName & " (" & $count & " issues)")
projectsCfg[context].add(ProjectCfg(name: projectName, milestoneOrder: @[]))
projectsCfgChanged = true
linesToPrint.add("")
if projectsCfgChanged: ctx.saveProjectsConfiguration(projectsCfg)
if isatty(stdout):
stdout.writeLine(linesToPrint.join("\p"))
else:
stdout.writeLine(stripAnsi(linesToPrint.join("\p")))
proc listMilestones*(ctx: CliContext, filter = none[IssueFilter]()) =
var linesToPrint = newSeq[string]()
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
linesToPrint.add(withColor(project.name, fgBlue, bold = true, bright=true))
linesToPrint.add(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
linesToPrint.add(" " & milestone & " (" & $issueCount & " issues)")
linesToPrint.add("")
if isatty(stdout):
stdout.writeLine(linesToPrint.join("\p"))
else:
stdout.writeLine(stripAnsi(linesToPrint.join("\p")))
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) =
var linesToPrint = newSeq[string]()
let fullWidth = terminalWidth() - 1
let columnWidth = 80
let numColumns = (fullWidth - 4) div (columnWidth + 2)
linesToPrint.add("")
linesToPrint.add(withColor(
"" & "".repeat(project.name.runeLen + 2) &
"" & "".repeat(fullWidth - project.name.runeLen - 4) & "",
fgBlue, bold=true))
linesToPrint.add(
withColor("", fgBlue, bold=true) &
withColor(project.name, fgBlue, bold=true, bright=true) &
withColor("" & " ".repeat(fullWidth - project.name.runeLen - 4) & "", fgBlue, bold=true))
linesToPrint.add(withColor(
"" & "".repeat(project.name.runeLen + 2) &
"" & " ".repeat(fullWidth - project.name.runeLen - 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
linesToPrint.add(
withColor("", fgBlue) &
line &
" ".repeat(padLen) &
withColor("", fgBlue))
linesToPrint.add(withColor(
"" & "".repeat(terminalWidth() - 2) & "",
fgBlue, bold=true))
if isatty(stdout):
stdout.writeLine(linesToPrint.join("\p"))
else:
stdout.writeLine(stripAnsi(linesToPrint.join("\p")))
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)

View File

@@ -19,9 +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]
@@ -33,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 =
@@ -43,6 +45,12 @@ 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(),
@@ -80,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)