From 2b5f82203cef975758087d3336054f00a477da94 Mon Sep 17 00:00:00 2001 From: Jonathan Bernard Date: Tue, 29 May 2018 14:20:40 -0500 Subject: [PATCH] Add `list contexts`, refactor display logics. * Refactor formatting logic to better calculate needed padding between issues and sections. * Add `list contexts` command to list all known contexts according to the contexts configuration and the contexts defined in issues. * Be more intentional about when the default context is used. Don't override existing context values in issues when changing their state. * `edit` now allows multiple issues to be edited. * Change single-issue display to be more verbose, listing all the properties and tags on an issue. --- pit.nimble | 2 +- src/pit.nim | 194 ++++++++++++++++++++------------- src/pitpkg/private/version.nim | 2 +- 3 files changed, 122 insertions(+), 76 deletions(-) diff --git a/pit.nimble b/pit.nimble index 00f6330..33f64b5 100644 --- a/pit.nimble +++ b/pit.nimble @@ -11,5 +11,5 @@ bin = @["pit", "pit_api"] # Dependencies -requires @[ "nim >= 0.18.0", "cliutils 0.3.5", "docopt 0.6.5", "jester 0.2.0", +requires @[ "nim >= 0.18.0", "cliutils 0.4.1", "docopt 0.6.5", "jester 0.2.0", "timeutils 0.3.0", "uuids 0.1.9" ] diff --git a/src/pit.nim b/src/pit.nim index 897ca74..b4605f4 100644 --- a/src/pit.nim +++ b/src/pit.nim @@ -12,12 +12,13 @@ include "pitpkg/private/version.nim" type CliContext = ref object - autoList, triggerPtk, verbose: bool cfg*: PitConfig contexts*: TableRef[string, string] - defaultContext*, tasksDir*: string + defaultContext*: Option[string] + tasksDir*: string issues*: TableRef[IssueState, seq[Issue]] termWidth*: int + triggerPtk*, verbose*: bool proc initContext(args: Table[string, Value]): CliContext = let pitCfg = loadConfig(args) @@ -29,9 +30,10 @@ proc initContext(args: Table[string, Value]): CliContext = let cliCfg = CombinedConfig(docopt: args, json: cliJson) result = CliContext( - autoList: cliJson.getOrDefault("autoList").getBool(false), contexts: pitCfg.contexts, - defaultContext: cliJson.getOrDefault("defaultContext").getStr(""), + defaultContext: + if not cliJson.hasKey("defaultContext"): none(string) + else: some(cliJson["defaultContext"].getStr()), verbose: parseBool(cliCfg.getVal("verbose", "false")) and not args["--quiet"], issues: newTable[IssueState, seq[Issue]](), tasksDir: pitCfg.tasksDir, @@ -44,77 +46,93 @@ proc getIssueContextDisplayName(ctx: CliContext, context: string): string = else: return context.capitalize() return ctx.contexts[context] -proc writeIssue(ctx: CliContext, issue: Issue, width: int, indent = "", - verbose = false, topPadded = false) = - var showDetails = not issue.details.isNilOrWhitespace and verbose +proc formatIssue(ctx: CliContext, issue: Issue): string = + result = ($issue.id).withColor(fgBlack, true) & "\n"& + issue.summary.withColor(fgWhite) & "\n" - if showDetails and not topPadded: stdout.writeLine("") + if issue.tags.len > 0: + result &= "tags: ".withColor(fgMagenta) & + 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" + + + result &= "--------".withColor(fgBlack, true) & "\n" + if not issue.details.isNilOrWhitespace: + result &= issue.details.strip.withColor(fgCyan) & "\n" + +proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "", + verbose = false): string = + + result = "" + + var showDetails = not issue.details.isNilOrWhitespace and verbose # Wrap and write the summary. var wrappedSummary = (" ".repeat(5) & issue.summary).wordWrap(width - 2).indent(2 + indent.len) wrappedSummary = wrappedSummary[(6 + indent.len)..^1] - stdout.setForegroundColor(fgBlack, true) - stdout.write(indent & ($issue.id)[0..<6]) - stdout.setForegroundColor(fgWhite, false) - stdout.write(wrappedSummary) + + result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true) + result &= wrappedSummary.withColor(fgWhite) if issue.tags.len > 0: - stdout.setForegroundColor(fgGreen, false) - let tagsStr = "(" & issue.tags.join(",") & ")" - if (wrappedSummary.splitLines[^1].len + tagsStr.len + 1) < (width - 2): - stdout.writeLine(" " & tagsStr) - else: - stdout.writeLine("\n" & indent & " " & tagsStr) - else: stdout.writeLine("") + let tagsStr = "(" & issue.tags.join(", ") & ")" + if (result.splitLines[^1].len + tagsStr.len + 1) > (width - 2): + result &= "\n" & indent + result &= " " & tagsStr.withColor(fgGreen) + if issue.hasProp("pending"): let startIdx = "Pending: ".len var pendingText = issue["pending"].wordWrap(width - startIdx - 2) .indent(startIdx) pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2) - stdout.setForegroundColor(fgCyan, false) - stdout.writeLine(pendingText) + result &= "\n" & pendingText.withColor(fgCyan) if showDetails: - stdout.setForegroundColor(fgCyan, false) - stdout.writeLine(issue.details.indent(indent.len + 2)) + result &= "\n" & issue.details.strip.indent(indent.len + 2).withColor(fgCyan) - stdout.resetAttributes + result &= termReset -proc writeSection(ctx: CliContext, issues: seq[Issue], state: IssueState, - indent = "", verbose = false) = +proc formatSectionIssueList(ctx: CliContext, issues: seq[Issue], width: int, + indent: string, verbose: bool): string = + + result = "" + var topPadded = true + for i in issues: + var issueText = ctx.formatSectionIssue(i, width, indent, verbose) + if issueText.splitLines.len > 1: + if topPadded: result &= issueText & "\n\n" + else: result &= "\n" & issueText & "\n\n" + topPadded = true + else: + result &= issueText & "\n" + topPadded = false + +proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState, + indent = "", verbose = false): string = let innerWidth = ctx.termWidth - (indent.len * 2) - stdout.setForegroundColor(fgBlue, true) - stdout.writeLine(indent & ".".repeat(innerWidth)) - stdout.writeLine(state.displayName.center(ctx.termWidth)) - stdout.writeLine("") - stdout.resetAttributes + result = termColor(fgBlue) & + (indent & ".".repeat(innerWidth)) & "\n" & + state.displayName.center(ctx.termWidth) & "\n\n" & + termReset let issuesByContext = issues.groupBy("context") - var topPadded = true - if issues.len > 5 and issuesByContext.len > 1: for context, ctxIssues in issuesByContext: - topPadded = true - stdout.setForegroundColor(fgYellow, false) - stdout.writeLine(indent & ctx.getIssueContextDisplayName(context) & ":") - stdout.writeLine("") - stdout.resetAttributes - for i in ctxIssues: - ctx.writeIssue(i, innerWidth - 2, indent & " ", verbose, topPadded) - topPadded = not i.details.isNilOrWhitespace and verbose + result &= termColor(fgYellow) & + indent & ctx.getIssueContextDisplayName(context) & ":" & + termReset & "\n\n" - if not topPadded: stdout.writeLine("") + result &= ctx.formatSectionIssueList(ctxIssues, innerWidth - 2, indent & " ", verbose) + result &= "\n" - else: - for i in issues: - ctx.writeIssue(i, innerWidth, indent, verbose, topPadded) - topPadded = not i.details.isNilOrWhitespace and verbose - - stdout.writeLine("") + else: result &= ctx.formatSectionIssueList(issues, innerWidth, indent, verbose) proc loadIssues(ctx: CliContext, state: IssueState) = ctx.issues[state] = loadIssues(ctx.tasksDir / $state) @@ -169,7 +187,7 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState if state.isSome: ctx.loadIssues(state.get) if filter.isSome: ctx.filterIssues(filter.get) - ctx.writeSection(ctx.issues[state.get], state.get, "", verbose) + stdout.write ctx.formatSection(ctx.issues[state.get], state.get, "", verbose) return ctx.loadAllIssues() @@ -183,14 +201,14 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState for s in [Current, TodoToday]: if ctx.issues.hasKey(s) and ctx.issues[s].len > 0: - ctx.writeSection(ctx.issues[s], s, indent, verbose) + stdout.write ctx.formatSection(ctx.issues[s], s, indent, verbose) if ctx.issues.hasKey(Done): let doneIssues = ctx.issues[Done].filterIt( it.hasProp("completed") and sameDay(getTime().local, it.getDateTime("completed"))) if doneIssues.len > 0: - ctx.writeSection(doneIssues, Done, indent, verbose) + stdout.write ctx.formatSection(doneIssues, Done, indent, verbose) # Future items if future: @@ -198,7 +216,7 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState for s in [Pending, Todo]: if ctx.issues.hasKey(s) and ctx.issues[s].len > 0: - ctx.writeSection(ctx.issues[s], s, indent, verbose) + stdout.write ctx.formatSection(ctx.issues[s], s, indent, verbose) when isMainModule: @@ -207,8 +225,8 @@ when isMainModule: Usage: pit ( new | add) [] [options] pit list [] [options] - pit ( start | done | pending | do-today | todo | suspend ) ... [options] - pit edit + pit ( start | done | pending | todo-today | todo | suspend ) ... [options] + pit edit ... pit ( delete | rm ) ... Options: @@ -258,22 +276,20 @@ Options: let ctx = initContext(args) var propertiesOption = none(TableRef[string,string]) + var tagsOption = none(seq[string]) - if args["--properties"] or args["--context"] or - not ctx.defaultContext.isNilOrWhitespace: + if args["--properties"] or args["--context"]: var props = if args["--properties"]: parsePropertiesOption($args["--properties"]) else: newTable[string,string]() - if args["--context"] and $args["--context"] != "all": - props["context"] = $args["--context"] - elif not args["--context"] and not ctx.defaultContext.isNilOrWhitespace: - stderr.writeLine("Limiting to default context: " & ctx.defaultContext) - props["context"] = ctx.defaultContext + if args["--context"]: props["context"] = $args["--context"] propertiesOption = some(props) + if args["--tags"]: tagsOption = some(($args["--tags"]).split(",").mapIt(it.strip)) + ## Actual command runners if args["new"] or args["add"]: let state = @@ -282,6 +298,9 @@ Options: 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(), @@ -293,17 +312,18 @@ Options: ctx.tasksDir.store(issue, state) + stdout.writeLine ctx.formatIssue(issue) + elif args["edit"]: - let issueId = $args[""] + for id in @(args[""]): + edit(ctx.tasksDir.loadIssueById(id)) - edit(ctx.tasksDir.loadIssueById(issueId)) - - elif args["start"] or args["do-today"] or args["done"] or + elif args["start"] or args["todo-today"] or args["done"] or args["pending"] or args["todo"] or args["suspend"]: var targetState: IssueState if args["done"]: targetState = Done - elif args["do-today"]: targetState = TodoToday + elif args["todo-today"]: targetState = TodoToday elif args["pending"]: targetState = Pending elif args["start"]: targetState = Current elif args["todo"]: targetState = Todo @@ -314,6 +334,7 @@ Options: if propertiesOption.isSome: for k,v in propertiesOption.get: issue[k] = v + if targetState == Done: issue["completed"] = getTime().local.formatIso8601 issue.changeState(ctx.tasksDir, targetState) if ctx.triggerPtk: @@ -342,20 +363,49 @@ Options: let filter = initFilter() var filterOption = none(IssueFilter) + + # Initialize filter with properties (if given) if propertiesOption.isSome: filter.properties = propertiesOption.get 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) + + # 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") + + var listContexts = false var stateOption = none(IssueState) var issueIdOption = none(string) + if args[""]: - try: stateOption = some(parseEnum[IssueState]($args[""])) - except: issueIdOption = some($args[""]) + if $args[""] == "contexts": listContexts = true + else: + try: stateOption = some(parseEnum[IssueState]($args[""])) + except: issueIdOption = some($args[""]) + + # List the known contexts + if listContexts: + var uniqContexts = toSeq(ctx.contexts.keys) + ctx.loadAllIssues() + for state, issueList in ctx.issues: + for issue in issueList: + if issue.hasProp("context") and not uniqContexts.contains(issue["context"]): + uniqContexts.add(issue["context"]) + + for c in uniqContexts: stdout.writeLine(c) # List a specific issue - if issueIdOption.isSome: + elif issueIdOption.isSome: let issue = ctx.tasksDir.loadIssueById(issueIdOption.get) - ctx.writeIssue(issue, ctx.termWidth, "", true, true) + stdout.writeLine ctx.formatIssue(issue) # List all issues else: @@ -364,10 +414,6 @@ Options: showBoth or args["--future"], ctx.verbose) - if ctx.autoList and not args["list"]: - ctx.loadAllIssues() - ctx.list(none(IssueFilter), none(IssueState), true, true, false) - except: fatal "pit: " & getCurrentExceptionMsg() #raise getCurrentException() diff --git a/src/pitpkg/private/version.nim b/src/pitpkg/private/version.nim index 4b5feec..96c11a5 100644 --- a/src/pitpkg/private/version.nim +++ b/src/pitpkg/private/version.nim @@ -1 +1 @@ -const PIT_VERSION = "4.1.0" +const PIT_VERSION = "4.2.0"