From 11b18317bd45422653fc42de4aefd4822bfb1b8c Mon Sep 17 00:00:00 2001 From: Jonathan Bernard Date: Sun, 13 May 2018 02:58:08 -0500 Subject: [PATCH] Implemented new, edit, state transitions. --- pit.nimble | 2 +- src/pit.nim | 280 ++++++++++++++++++++++++++----------- src/pit/private/libpit.nim | 88 ++++++++++-- 3 files changed, 276 insertions(+), 94 deletions(-) diff --git a/pit.nimble b/pit.nimble index b861344..bf17eea 100644 --- a/pit.nimble +++ b/pit.nimble @@ -9,4 +9,4 @@ bin = @["pit"] # Dependencies -requires @["nim >= 0.18.1", "uuids 0.1.9", "docopt 0.6.5", "cliutils 0.3.3"] +requires @["nim >= 0.18.0", "uuids 0.1.9", "docopt 0.6.5", "cliutils 0.3.4", "timeutils 0.3.0"] diff --git a/src/pit.nim b/src/pit.nim index 3206a8b..99add89 100644 --- a/src/pit.nim +++ b/src/pit.nim @@ -1,20 +1,21 @@ ## Personal Issue Tracker ## ====================== -## +## -import cliutils, docopt, json, logging, os, ospaths, sequtils, strutils, - tables, times, unicode, uuids +import cliutils, docopt, json, logging, options, os, ospaths, sequtils, + tables, terminal, times, unicode, uuids +import strutils except capitalize import pit/private/libpit export libpit type CliContext = ref object + autoList, triggerPtk: bool tasksDir*: string contexts*: TableRef[string, string] issues*: TableRef[IssueState, seq[Issue]] termWidth*: int - cfg*: CombinedConfig proc initContext(args: Table[string, Value]): CliContext = let pitrcLocations = @[ @@ -44,11 +45,12 @@ proc initContext(args: Table[string, Value]): CliContext = let cfg = CombinedConfig(docopt: args, json: cfgJson) result = CliContext( - cfg: cfg, - tasksDir: cfg.getVal("tasks-dir", ""), + autoList: cfgJson.getOrDefault("autoList").getBool(false), contexts: newTable[string,string](), issues: newTable[IssueState, seq[Issue]](), - termWidth: parseInt(cfg.getVal("term-width", "80"))) + tasksDir: cfg.getVal("tasks-dir", ""), + termWidth: parseInt(cfg.getVal("term-width", "80")), + triggerPtk: cfgJson.getOrDefault("triggerPtk").getBool(false)) if cfgJson.hasKey("contexts"): for k, v in cfgJson["contexts"]: @@ -61,39 +63,54 @@ proc initContext(args: Table[string, Value]): CliContext = raise newException(Exception, "cannot find tasks dir: " & result.tasksDir) proc getIssueContextDisplayName(ctx: CliContext, context: string): string = - if not ctx.contexts.hasKey(context): return context.capitalize() + if not ctx.contexts.hasKey(context): + if context.isNilOrWhitespace: return "" + else: return context.capitalize() return ctx.contexts[context] -proc formatIssue(ctx: CliContext, issue: Issue, state: IssueState, - width: int, indent: string, topPadded: bool): string = +proc writeIssue(ctx: CliContext, issue: Issue, state: IssueState, + width: int, indent: string, topPadded: bool) = var showDetails = not issue.details.isNilOrWhitespace - var lines: seq[string] = @[] - if showDetails and not topPadded: lines.add("") + if showDetails and not topPadded: stdout.writeLine("") - var wrappedSummary = issue.summary.wordWrap(width - 2).indent(2) - wrappedSummary = "*" & wrappedSummary[1..^1] - lines.add(wrappedSummary.indent(indent.len)) + # 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(fgCyan, false) + stdout.write(wrappedSummary) - if state == Pending and issue.properties.hasKey("pending"): + 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("") + stdout.resetAttributes + + if state == Pending and 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) - lines.add(pendingText) + stdout.writeLine(pendingText) - if showDetails: lines.add(issue.details.indent(indent.len + 2)) + if showDetails: stdout.writeLine(issue.details.indent(indent.len + 2)) - return lines.join("\n") -proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState, - indent = ""): string = +proc writeSection(ctx: CliContext, issues: seq[Issue], state: IssueState, + indent = "") = let innerWidth = ctx.termWidth - (indent.len * 2) - var lines: seq[string] = @[] - lines.add(indent & ".".repeat(innerWidth)) - lines.add(state.displayName.center(ctx.termWidth)) - lines.add("") + stdout.setForegroundColor(fgBlue, true) + stdout.writeLine(indent & ".".repeat(innerWidth)) + stdout.writeLine(state.displayName.center(ctx.termWidth)) + stdout.writeLine("") + stdout.resetAttributes var topPadded = true @@ -101,64 +118,140 @@ proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState, if issues.len > 5 and issuesByContext.len > 1: for context, ctxIssues in issuesByContext: - lines.add(indent & ctx.getIssueContextDisplayName(context) & ":") - lines.add("") + stdout.setForegroundColor(fgYellow, false) + stdout.writeLine(indent & ctx.getIssueContextDisplayName(context) & ":") + stdout.writeLine("") + stdout.resetAttributes for i in ctxIssues: - lines.add(ctx.formatIssue(i, state, innerWidth - 2, indent & " ", topPadded)) + ctx.writeIssue(i, state, innerWidth - 2, indent & " ", topPadded) topPadded = not i.details.isNilOrWhitespace - if not topPadded: lines.add("") + if not topPadded: stdout.writeLine("") else: for i in issues: - lines.add(ctx.formatIssue(i, state, innerWidth, indent, topPadded)) + ctx.writeIssue(i, state, innerWidth, indent, topPadded) topPadded = not i.details.isNilOrWhitespace - lines.add("") - return lines.join("\n") + stdout.writeLine("") proc loadIssues(ctx: CliContext, state: IssueState) = - ctx.issues[state] = loadIssues(joinPath(ctx.tasksDir, $state)) + ctx.issues[state] = loadIssues(ctx.tasksDir / $state) proc loadAllIssues(ctx: CliContext) = + ctx.issues = newTable[IssueState, seq[Issue]]() for state in IssueState: ctx.loadIssues(state) +proc filterIssues(ctx: CliContext, filter: IssueFilter) = + for state, issueList in ctx.issues: + ctx.issues[state] = issueList.filter(filter) + +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" + else: result[pair[0]] = pair[1] + proc sameDay(a, b: DateTime): bool = result = a.year == b.year and a.yearday == b.yearday -proc formatHeader(ctx: CliContext, header: string): string = - var lines: seq[string] = @[] - lines.add('_'.repeat(ctx.termWidth)) - lines.add(header.center(ctx.termWidth)) - lines.add('~'.repeat(ctx.termWidth)) - lines.add("") - return lines.join("\n") +proc writeHeader(ctx: CliContext, header: string) = + stdout.setForegroundColor(fgRed, true) + stdout.writeLine('_'.repeat(ctx.termWidth)) + stdout.writeLine(header.center(ctx.termWidth)) + stdout.writeLine('~'.repeat(ctx.termWidth)) + stdout.resetAttributes + +proc edit(issue: Issue) = + + # Write format comments (to help when editing) + writeFile(issue.filepath, toStorageFormat(issue, true)) + + let editor = + if existsEnv("EDITOR"): getEnv("EDITOR") + else: "vi" + + discard os.execShellCmd(editor & " " & issue.filepath & " /dev/tty") + + try: + # Try to parse the newly-edited issue to make sure it was successful. + let editedIssue = loadIssue(issue.filepath) + editedIssue.store() + except: + fatal "pit: updated issue is invalid (ignoring edits): \n\t" & + getCurrentExceptionMsg() + issue.store() + +proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState], today, future: bool) = + + if state.isSome: + ctx.loadIssues(state.get) + if filter.isSome: ctx.filterIssues(filter.get) + ctx.writeSection(ctx.issues[state.get], state.get) + return + + ctx.loadAllIssues() + if filter.isSome: ctx.filterIssues(filter.get) + + let indent = if today and future: " " else: "" + + # Today's items + if today: + if future: ctx.writeHeader("Today") + + for s in [Current, TodoToday]: + if ctx.issues.hasKey(s) and ctx.issues[s].len > 0: + ctx.writeSection(ctx.issues[s], s, indent) + + 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) + + # Future items + if future: + if today: ctx.writeHeader("Future") + + for s in [Pending, Todo]: + if ctx.issues.hasKey(s) and ctx.issues[s].len > 0: + ctx.writeSection(ctx.issues[s], s, indent) when isMainModule: try: let doc = """ Usage: - pit new [options] + pit new [] [options] pit list [] [options] - pit today - pit start - pit done - pit pending - pit edit + pit ( start | done | pending | do-today | todo ) ... + pit edit Options: - -t, --tags Specify tags for an issue. - -p, --properties Specify properties for an issue. Formatted as "key:val;key:val" - -C, --config Location of the config file (defaults to $HOME/.pitrc) -h, --help Print this usage information. + + -t, --tags Specify tags for an issue. + + -p, --properties Specify properties. Formatted as "key:val;key:val" + When used with the list command this option applies + a filter to the issues listed, only allowing those + which have all of the given properties. + -T, --today Limit to today's issues. + -F, --future Limit to future issues. + + -C, --config Location of the config file (defaults to $HOME/.pitrc) + -E, --echo-args Echo arguments (for debug purposes). + --tasks-dir Path to the tasks directory (defaults to the value configured in the .pitrc file) + --term-width Manually set the terminal width to use. """ @@ -167,53 +260,78 @@ Options: # Parse arguments let args = docopt(doc, version = "ptk 0.12.1") - if args["--echo-args"]: echo $args + if args["--echo-args"]: stderr.writeLine($args) if args["--help"]: - echo doc + stderr.writeLine(doc) quit() - let now = getTime().local - let ctx = initContext(args) ## Actual command runners - if args["list"]: + if args["new"]: + let state = + if args[""]: parseEnum[IssueState]($args[""]) + else: TodoToday - # Specific state request - if args[""]: - let state = parseEnum[IssueState]($args[""]) - ctx.loadIssues(state) - echo ctx.formatSection(ctx.issues[state], state) + var issue = Issue( + id: genUUID(), + summary: $args[""], + properties: + if args["--properties"]: parsePropertiesOption($args["--properties"]) + else: newTable[string,string](), + tags: + if args["--tags"]: ($args["tags"]).split(",").mapIt(it.strip) + else: newSeq[string]()) - else: + ctx.tasksDir.store(issue, state) - let showBoth = args["--today"] == args["--future"] - let indent = if showBoth: " " else: "" - ctx.loadAllIssues() + elif args["edit"]: + let issueId = $args[""] - # Today's items - if args["--today"] or showBoth: - if showBoth: echo ctx.formatHeader("Today") + edit(ctx.tasksDir.loadIssueById(issueId)) - for s in [Current, TodoToday]: - if ctx.issues.hasKey(s) and ctx.issues[s].len > 0: - echo ctx.formatSection(ctx.issues[s], s, indent) + elif args["start"] or args["do-today"] or args["done"] or + args["pending"] or args["todo"]: - if ctx.issues.hasKey(Done): - let doneIssues = ctx.issues[Done].filterIt( - it.properties.hasKey("completed") and - sameDay(now, it.getDateTime("completed"))) - if doneIssues.len > 0: - echo ctx.formatSection(doneIssues, Done, indent) + var targetState: IssueState + if args["done"]: targetState = Done + elif args["do-today"]: targetState = TodoToday + elif args["pending"]: targetState = Todo + elif args["start"]: targetState = Current + elif args["todo"]: targetState = Todo - # Future items - if args["--future"] or showBoth: - if showBoth: echo ctx.formatHeader("Future") + for id in @(args[""]): + ctx.tasksDir.moveIssue(ctx.tasksDir.loadIssueById(id), targetState) - for s in [Pending, Todo]: - if ctx.issues.hasKey(s) and ctx.issues[s].len > 0: - echo ctx.formatSection(ctx.issues[s], s, indent) + if ctx.triggerPtk: + if targetState == Current: + let issue = ctx.tasksDir.loadIssueById($(args[""][0])) + var cmd = "ptk start " + if issue.tags.len > 0: cmd &= "-g \"" & issue.tags.join(",") & "\"" + cmd &= " \"" & issue.summary & "\"" + discard execShellCmd(cmd) + elif targetState == Done: discard execShellCmd("ptk stop") + + elif args["list"]: + + let filter = initFilter() + var filterOption = none(IssueFilter) + if args["--properties"]: + filter.properties = parsePropertiesOption($args["--properties"]) + filterOption = some(filter) + + let stateOption = + if args[""]: some(parseEnum[IssueState]($args[""])) + else: none(IssueState) + + let showBoth = args["--today"] == args["--future"] + ctx.list(filterOption, stateOption, showBoth or args["--today"], + showBoth or args["--future"]) + + if ctx.autoList and not args["list"]: + ctx.loadAllIssues() + ctx.list(none(IssueFilter), none(IssueState), true, true) except: fatal "pit: " & getCurrentExceptionMsg() diff --git a/src/pit/private/libpit.nim b/src/pit/private/libpit.nim index 3db870a..b4565cc 100644 --- a/src/pit/private/libpit.nim +++ b/src/pit/private/libpit.nim @@ -1,4 +1,4 @@ -import cliutils, options, os, ospaths, sequtils, strutils, tables, times, uuids +import cliutils, options, os, ospaths, sequtils, strutils, tables, times, timeutils, uuids from nre import re, match type @@ -16,7 +16,10 @@ type Done = "done", Todo = "todo" -const ISO8601Format* = "yyyy:MM:dd'T'HH:mm:sszzz" + IssueFilter* = ref object + properties*: TableRef[string, string] + completedRange*: tuple[b, e: DateTime] + const DONE_FOLDER_FORMAT* = "yyyy-MM" let ISSUE_FILE_PATTERN = re"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}\.txt" @@ -36,11 +39,37 @@ proc `[]`*(issue: Issue, key: string): string = proc `[]=`*(issue: Issue, key: string, value: string) = issue.properties[key] = value +proc hasProp*(issue: Issue, key: string): bool = + return issue.properties.hasKey(key) + proc getDateTime*(issue: Issue, key: string): DateTime = - return parse(issue.properties[key], ISO8601Format) + return issue.properties[key].parseIso8601 + +proc getDateTime*(issue: Issue, key: string, default: DateTime): DateTime = + if issue.properties.hasKey(key): return issue.properties[key].parseIso8601 + else: return default proc setDateTime*(issue: Issue, key: string, dt: DateTime) = - issue.properties[key] = format(dt, ISO8601Format) + issue.properties[key] = dt.formatIso8601 + +proc initFilter*(): IssueFilter = + result = IssueFilter( + properties: newTable[string,string](), + completedRange: (fromUnix(0).local, fromUnix(253400659199).local)) + +proc initFilter*(props: TableRef[string, string]): IssueFilter = + if isNil(props): + raise newException(ValueError, + "cannot initialize property filter without properties") + + result = IssueFilter( + properties: props, + completedRange: (fromUnix(0).local, fromUnix(253400659199).local)) + +proc initFilter*(range: tuple[b, e: DateTime]): IssueFilter = + result = IssueFilter( + properties: newTable[string, string](), + completedRange: range) ## Parse and format issues proc fromStorageFormat*(id: string, issueTxt: string): Issue = @@ -86,24 +115,44 @@ proc fromStorageFormat*(id: string, issueTxt: string): Issue = result.details = if detailLines.len > 0: detailLines.join("\n") else: "" -proc toStorageFormat*(issue: Issue): string = - var lines = @[issue.summary] +proc toStorageFormat*(issue: Issue, withComments = false): string = + var lines: seq[string] = @[] + if withComments: lines.add("# Summary (one line):") + lines.add(issue.summary) + if withComments: lines.add("# Properties (\"key:value\" per line):") for key, val in issue.properties: lines.add(key & ": " & val) if issue.tags.len > 0: lines.add("tags: " & issue.tags.join(",")) - if not isNilOrWhitespace(issue.details): + if not isNilOrWhitespace(issue.details) or withComments: + if withComments: lines.add("# Details go below the \"--------\"") lines.add("--------") lines.add(issue.details) result = lines.join("\n") - + ## Load and store from filesystem proc loadIssue*(filePath: string): Issue = result = fromStorageFormat(splitFile(filePath).name, readFile(filePath)) result.filepath = filePath -proc storeIssue*(dirPath: string, issue: Issue) = - issue.filepath = joinPath(dirPath, $issue.id & ".txt") - writeFile(issue.filepath, toStorageFormat(issue)) +proc loadIssueById*(tasksDir, id: string): Issue = + for path in walkDirRec(tasksDir): + if path.splitFile.name.startsWith(id): + return loadIssue(path) + raise newException(KeyError, "cannot find issue for id: " & id) + +proc store*(issue: Issue, withComments = false) = + writeFile(issue.filepath, toStorageFormat(issue, withComments)) + +proc store*(tasksDir: string, issue: Issue, state: IssueState, withComments = false) = + let stateDir = tasksDir / $state + let filename = $issue.id & ".txt" + if state == Done: + let monthPath = issue.getDateTime("completed", getTime().local).format(DONE_FOLDER_FORMAT) + issue.filepath = stateDir / monthPath / filename + else: + issue.filepath = stateDir / filename + + issue.store() proc loadIssues*(path: string): seq[Issue] = result = @[] @@ -111,12 +160,27 @@ proc loadIssues*(path: string): seq[Issue] = if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome(): result.add(loadIssue(path)) +proc moveIssue*(tasksDir: string, issue: Issue, newState: IssueState) = + removeFile(issue.filepath) + if newState == Done: issue.setDateTime("completed", getTime().local) + tasksDir.store(issue, newState) + ## Utilities for working with issue collections. proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] = result = newTable[string, seq[Issue]]() for i in issues: - let key = if i.properties.hasKey(propertyKey): i[propertyKey] else: "" + let key = if i.hasProp(propertyKey): i[propertyKey] else: "" if not result.hasKey(key): result[key] = newSeq[Issue]() result[key].add(i) +proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] = + result = issues + + for k,v in filter.properties: + result = result.filterIt(it.hasProp(k) and it[k] == v) + + result = result.filterIt(not it.hasProp("completed") or + it.getDateTime("completed").between( + filter.completedRange.b, + filter.completedRange.e))