Compare commits

...

7 Commits

8 changed files with 567 additions and 84 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.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",
@@ -28,4 +27,4 @@ requires @[
]
task updateVersion, "Update the version of this package.":
exec "update_nim_package_version pit 'src/pit/cliconstants.nim'"
exec "update_nim_package_version pit 'src/pit/cliconstants.nim'"

View File

@@ -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.
@@ -87,6 +88,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,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 =
@@ -224,7 +277,6 @@ when isMainModule:
updatedIssues.add(nextIssue)
stdout.writeLine formatIssue(nextIssue)
issue.changeState(ctx.cfg.tasksDir, targetState)
updatedIssues.add(issue)
@@ -276,55 +328,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 +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"
@@ -384,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>"]))
@@ -412,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"

View File

@@ -1,8 +1,10 @@
const PIT_VERSION* = "4.29.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.
@@ -144,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
@@ -152,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
@@ -191,4 +235,4 @@ Issue Properties:
If present, expected to be a comma-delimited list of text tags. The -g
option is a short-hand for '-p tags:<tags-value>'.
"""
"""

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)

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,18 +19,22 @@ 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]
@@ -129,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 =
@@ -136,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](),
@@ -165,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:
@@ -426,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*(
@@ -513,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
View 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)

View File

@@ -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)