Compare commits
11 Commits
4.28.0
...
5dd7a15bf4
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dd7a15bf4 | |||
| 6ac068fe75 | |||
| 759d00e2f8 | |||
| bc37640f2e | |||
| 3ee5bdf8fd | |||
| 85d561c8a5 | |||
| 1064de3e1b | |||
| 8b0c751344 | |||
| 0f7e257f76 | |||
| 76225d1c50 | |||
| 0361d1b869 |
@@ -1,2 +1,2 @@
|
||||
[tools]
|
||||
nim = "2.2.0"
|
||||
nim = "2.2.6"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Package
|
||||
|
||||
version = "4.28.0"
|
||||
version = "4.30.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",
|
||||
|
||||
175
src/pit.nim
175
src/pit.nim
@@ -6,7 +6,7 @@ import data_uri, docopt, json, timeutils, uuids
|
||||
|
||||
from nre import 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,6 +31,7 @@ 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.
|
||||
@@ -67,6 +68,9 @@ when isMainModule:
|
||||
if args["--debug"]:
|
||||
consoleLogger.levelThreshold = lvlDebug
|
||||
|
||||
if args["--silent"]:
|
||||
consoleLogger.levelThreshold = lvlNone
|
||||
|
||||
if args["--echo-args"]: stderr.writeLine($args)
|
||||
|
||||
if args["help"]:
|
||||
@@ -78,11 +82,16 @@ when isMainModule:
|
||||
|
||||
trace "context initiated"
|
||||
|
||||
var updatedIssues = newSeq[Issue]()
|
||||
var propertiesOption = none(TableRef[string,string])
|
||||
var exclPropsOption = none(TableRef[string,seq[string]])
|
||||
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 =
|
||||
@@ -112,6 +121,54 @@ 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 =
|
||||
@@ -133,7 +190,7 @@ when isMainModule:
|
||||
else: newSeq[string]())
|
||||
|
||||
ctx.cfg.tasksDir.store(issue, state)
|
||||
|
||||
updatedIssues.add(issue)
|
||||
stdout.writeLine formatIssue(issue)
|
||||
|
||||
elif args["reorder"]:
|
||||
@@ -160,6 +217,7 @@ when isMainModule:
|
||||
for k,v in propsOption.get:
|
||||
issue[k] = v
|
||||
edit(issue)
|
||||
updatedIssues.add(issue)
|
||||
|
||||
else:
|
||||
let issue = ctx.cfg.tasksDir.loadIssueById(editRef)
|
||||
@@ -167,6 +225,7 @@ when isMainModule:
|
||||
for k,v in propertiesOption.get:
|
||||
issue[k] = v
|
||||
edit(issue)
|
||||
updatedIssues.add(issue)
|
||||
|
||||
elif args["tag"]:
|
||||
if tagsOption.isNone: raise newException(Exception, "no tags given")
|
||||
@@ -177,6 +236,7 @@ when isMainModule:
|
||||
var issue = ctx.cfg.tasksDir.loadIssueById(id)
|
||||
issue.tags = deduplicate(issue.tags & newTags)
|
||||
issue.store()
|
||||
updatedIssues.add(issue)
|
||||
|
||||
elif args["untag"]:
|
||||
let tagsToRemove: seq[string] =
|
||||
@@ -190,6 +250,7 @@ when isMainModule:
|
||||
proc (tag: string): bool = not tagsToRemove.anyIt(it == tag))
|
||||
else: issue.tags = @[]
|
||||
issue.store()
|
||||
updatedIssues.add(issue)
|
||||
|
||||
elif args["start"] or args["todo-today"] or args["done"] or
|
||||
args["pending"] or args["todo"] or args["suspend"]:
|
||||
@@ -213,10 +274,11 @@ when isMainModule:
|
||||
let nextIssue = ctx.cfg.tasksDir.nextRecurrence(issue.getRecurrence.get, issue)
|
||||
ctx.cfg.tasksDir.store(nextIssue, TodoToday)
|
||||
info "created the next recurrence:"
|
||||
updatedIssues.add(nextIssue)
|
||||
stdout.writeLine formatIssue(nextIssue)
|
||||
|
||||
|
||||
issue.changeState(ctx.cfg.tasksDir, targetState)
|
||||
updatedIssues.add(issue)
|
||||
|
||||
if ctx.triggerPtk or args["--ptk"]:
|
||||
if targetState == Current:
|
||||
@@ -241,6 +303,7 @@ when isMainModule:
|
||||
issue.setDateTime("hide-until", parseDate($args["<date>"]))
|
||||
|
||||
issue.store()
|
||||
updatedIssues.add(issue)
|
||||
|
||||
elif args["delegate"]:
|
||||
|
||||
@@ -248,6 +311,7 @@ when isMainModule:
|
||||
issue["delegated-to"] = $args["<delegated-to>"]
|
||||
|
||||
issue.store()
|
||||
updatedIssues.add(issue)
|
||||
|
||||
elif args["delete"] or args["rm"]:
|
||||
for id in @(args["<id>"]):
|
||||
@@ -260,58 +324,20 @@ when isMainModule:
|
||||
continue
|
||||
|
||||
issue.delete
|
||||
updatedIssues.add(issue)
|
||||
|
||||
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:
|
||||
@@ -359,6 +385,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"
|
||||
@@ -371,6 +403,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>"]))
|
||||
|
||||
@@ -382,6 +440,7 @@ when isMainModule:
|
||||
finally: close(propIn)
|
||||
|
||||
issue.store()
|
||||
updatedIssues.add(issue)
|
||||
|
||||
elif args["get-binary-property"]:
|
||||
let issue = ctx.cfg.tasksDir.loadIssueById($(args["<id>"]))
|
||||
@@ -398,21 +457,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"
|
||||
@@ -422,6 +466,15 @@ when isMainModule:
|
||||
|
||||
sync(syncCtx, args["--dry-run"])
|
||||
|
||||
# after doing stuff, sync if auto-sync is requested
|
||||
if ctx.cfg.autoSync:
|
||||
for syncTarget in ctx.cfg.syncTargets:
|
||||
let syncCtx = initSyncContext(ctx.cfg, syncTarget)
|
||||
if anyIt(
|
||||
updatedIssues,
|
||||
it.hasProp("context") and it["context"] == syncCtx.issueContext):
|
||||
sync(syncCtx, false)
|
||||
|
||||
except CatchableError:
|
||||
fatal getCurrentExceptionMsg()
|
||||
debug getCurrentException().getStackTrace()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
const PIT_VERSION* = "4.28.0"
|
||||
const PIT_VERSION* = "4.30.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.
|
||||
@@ -82,6 +87,8 @@ Options:
|
||||
--dry-run Currently only supported by the `sync` command:
|
||||
only print the changes that would be made, but do
|
||||
not actually make them.
|
||||
|
||||
-s, --silent Suppress all logging and status output.
|
||||
"""
|
||||
|
||||
const ONLINE_HELP* = """Issue States:
|
||||
@@ -142,6 +149,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
|
||||
@@ -150,6 +189,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,14 +1,9 @@
|
||||
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 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 adjustedTerminalWidth(): int = min(terminalWidth(), 80)
|
||||
|
||||
proc formatIssue*(issue: Issue): string =
|
||||
result = ($issue.id).withColor(fgBlack, true) & "\n"&
|
||||
@@ -19,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:
|
||||
@@ -53,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) & " "
|
||||
|
||||
@@ -66,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)
|
||||
@@ -128,11 +137,11 @@ proc formatSectionIssueList*(
|
||||
|
||||
proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
|
||||
indent = "", verbose = false): string =
|
||||
let innerWidth = terminalWidth() - (indent.len * 2)
|
||||
let innerWidth = adjustedTerminalWidth() - (indent.len * 2)
|
||||
|
||||
result = termColor(fgBlue) &
|
||||
(indent & ".".repeat(innerWidth)) & "\n" &
|
||||
state.displayName.center(terminalWidth()) & "\n\n" &
|
||||
state.displayName.center(adjustedTerminalWidth()) & "\n\n" &
|
||||
termReset
|
||||
|
||||
let issuesByContext = issues.groupBy("context")
|
||||
@@ -152,9 +161,9 @@ proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
|
||||
|
||||
proc writeHeader*(ctx: CliContext, header: string) =
|
||||
stdout.setForegroundColor(fgRed, true)
|
||||
stdout.writeLine('_'.repeat(terminalWidth()))
|
||||
stdout.writeLine(header.center(terminalWidth()))
|
||||
stdout.writeLine('~'.repeat(terminalWidth()))
|
||||
stdout.writeLine('_'.repeat(adjustedTerminalWidth()))
|
||||
stdout.writeLine(header.center(adjustedTerminalWidth()))
|
||||
stdout.writeLine('~'.repeat(adjustedTerminalWidth()))
|
||||
stdout.resetAttributes
|
||||
|
||||
|
||||
|
||||
@@ -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,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]
|
||||
autoSync*: bool
|
||||
syncTargets*: seq[JsonNode]
|
||||
cfg*: CombinedConfig
|
||||
|
||||
@@ -128,6 +134,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 =
|
||||
@@ -135,6 +148,8 @@ proc initFilter*(): IssueFilter =
|
||||
completedRange: none(tuple[b, e: DateTime]),
|
||||
fullMatch: none(Regex),
|
||||
summaryMatch: none(Regex),
|
||||
inclStates: @[],
|
||||
exclStates: @[],
|
||||
hasTags: @[],
|
||||
exclTags: @[],
|
||||
properties: newTable[string, string](),
|
||||
@@ -164,6 +179,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:
|
||||
@@ -425,6 +444,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*(
|
||||
@@ -459,6 +484,7 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
|
||||
|
||||
result = PitConfig(
|
||||
cfg: cfg,
|
||||
autoSync: parseBool(cfg.getVal("auto-sync", "false")),
|
||||
contexts: newTable[string,string](),
|
||||
tasksDir: cfg.getVal("tasks-dir", ""),
|
||||
syncTargets: cfg.getJson("sync-targets", newJArray()).getElems)
|
||||
@@ -511,3 +537,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]
|
||||
|
||||
361
src/pit/projects.nim
Normal file
361
src/pit/projects.nim
Normal file
@@ -0,0 +1,361 @@
|
||||
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)
|
||||
|
||||
for (context, projects) in pairs(projectsDb):
|
||||
if projectsDb.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)
|
||||
@@ -4,11 +4,11 @@ import timeutils, uuids, zero_functional
|
||||
import ./formatting, ./libpit
|
||||
|
||||
type
|
||||
PbmVsbSyncContext = object
|
||||
PbmVsbSyncContext* = object
|
||||
apiBaseUrl*: string
|
||||
apiToken*: string
|
||||
issueContext*: string
|
||||
pit: PitConfig
|
||||
apiBaseUrl: string
|
||||
apiToken: string
|
||||
issueContext: string
|
||||
http: HttpClient
|
||||
|
||||
ServerTask* = object
|
||||
@@ -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)
|
||||
@@ -117,7 +129,6 @@ proc sync*(
|
||||
dryRun = true,
|
||||
batchSize = 100): void =
|
||||
|
||||
if not dryRun: echo "NOT DRY RUN"
|
||||
# We're going to do a uni-directional sync, pushing local issues to the
|
||||
# server. However, we only want to update issues that have changed since
|
||||
# the last sync based on the *last-updated* property.
|
||||
|
||||
Reference in New Issue
Block a user