## Personal Issue Tracker CLI interface ## ==================================== import algorithm, cliutils, data_uri, docopt, json, logging, options, os, sequtils, std/wordwrap, tables, terminal, times, timeutils, unicode, uuids from nre import re import strutils except alignLeft, capitalize, strip, toUpper, toLower import pitpkg/private/libpit import pitpkg/cliconstants export libpit type CliContext = ref object cfg*: PitConfig contexts*: TableRef[string, string] defaultContext*: Option[string] tasksDir*: string issues*: TableRef[IssueState, seq[Issue]] termWidth*: int triggerPtk*, verbose*: bool let EDITOR = if existsEnv("EDITOR"): getEnv("EDITOR") else: "vi" proc initContext(args: Table[string, Value]): CliContext = let pitCfg = loadConfig(args) let cliJson = if pitCfg.cfg.json.hasKey("cli"): pitCfg.cfg.json["cli"] else: newJObject() let cliCfg = CombinedConfig(docopt: args, json: cliJson) result = CliContext( cfg: pitCfg, contexts: pitCfg.contexts, 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, termWidth: parseInt(cliCfg.getVal("termWidth", "80")), triggerPtk: cliJson.getOrDefault("triggerPtk").getBool(false)) proc getIssueContextDisplayName(ctx: CliContext, context: string): string = if not ctx.contexts.hasKey(context): if context.isEmptyOrWhitespace: return "" else: return context.capitalize() return ctx.contexts[context] proc formatIssue(ctx: CliContext, issue: Issue): string = result = ($issue.id).withColor(fgBlack, true) & "\n"& issue.summary.withColor(fgWhite) & "\n" 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.isEmptyOrWhitespace: result &= issue.details.strip.withColor(fgCyan) & "\n" result &= termReset proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "", verbose = false): string = result = "" var showDetails = not issue.details.isEmptyOrWhitespace and verbose var prefixLen = 0 var summaryIndentLen = indent.len + 7 if issue.hasProp("delegated-to"): prefixLen += issue["delegated-to"].len + 2 # space for the ':' and ' ' # Wrap and write the summary. var wrappedSummary = ("+".repeat(prefixLen) & issue.summary).wrapWords(width - summaryIndentLen).indent(summaryIndentLen) wrappedSummary = wrappedSummary[(prefixLen + summaryIndentLen)..^1] result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true) & " " if issue.hasProp("delegated-to"): result &= (issue["delegated-to"] & ": ").withColor(fgGreen) result &= wrappedSummary.withColor(fgWhite) if issue.tags.len > 0: 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"].wrapWords(width - startIdx - summaryIndentLen) .indent(startIdx) pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(summaryIndentLen) result &= "\n" & pendingText.withColor(fgCyan) if showDetails: result &= "\n" & issue.details.strip.indent(indent.len + 2).withColor(fgCyan) result &= termReset proc formatSectionIssueList(ctx: CliContext, issues: seq[Issue], width: int, indent: string, verbose: bool): string = result = "" for i in issues: var issueText = ctx.formatSectionIssue(i, width, indent, verbose) result &= issueText & "\n" proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState, indent = "", verbose = false): string = let innerWidth = ctx.termWidth - (indent.len * 2) result = termColor(fgBlue) & (indent & ".".repeat(innerWidth)) & "\n" & state.displayName.center(ctx.termWidth) & "\n\n" & termReset let issuesByContext = issues.groupBy("context") if issues.len > 5 and issuesByContext.len > 1: for context, ctxIssues in issuesByContext: result &= termColor(fgYellow) & indent & ctx.getIssueContextDisplayName(context) & ":" & termReset & "\n\n" result &= ctx.formatSectionIssueList(ctxIssues, innerWidth - 2, indent & " ", verbose) result &= "\n" else: result &= ctx.formatSectionIssueList(issues, innerWidth, indent, verbose) proc loadIssues(ctx: CliContext, state: IssueState) = ctx.issues[state] = loadIssues(ctx.tasksDir / $state) proc loadOpenIssues(ctx: CliContext) = ctx.issues = newTable[IssueState, seq[Issue]]() for state in [Current, TodoToday, Todo, Pending, Todo]: ctx.loadIssues(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 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 reorder(ctx: CliContext, state: IssueState) = # load the issues to make sure the order file contains all issues in the state. ctx.loadIssues(state) discard os.execShellCmd(EDITOR & " '" & (ctx.tasksDir / $state / "order.txt") & "' /dev/tty") proc edit(issue: Issue) = # Write format comments (to help when editing) writeFile(issue.filepath, toStorageFormat(issue, true)) 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 "updated issue is invalid (ignoring edits): \n\t" & getCurrentExceptionMsg() issue.store() proc list( ctx: CliContext, filter: Option[IssueFilter], states: Option[seq[IssueState]], showToday, showFuture, showHidden = false, verbose: bool) = if states.isSome: trace "listing issues for " & $states.get for state in states.get: ctx.loadIssues(state) if filter.isSome: ctx.filterIssues(filter.get) # Show Done for just today if requested if state == Done and showToday: ctx.issues[Done] = ctx.issues[Done].filterIt( it.hasProp("completed") and sameDay(getTime().local, it.getDateTime("completed"))) stdout.write ctx.formatSection(ctx.issues[state], state, "", verbose) trace "listing complete" return ctx.loadOpenIssues() if filter.isSome: ctx.filterIssues(filter.get) trace "filtered issues" let today = showToday and [Current, TodoToday, Pending].anyIt( ctx.issues.hasKey(it) and ctx.issues[it].len > 0) let future = showFuture and [Pending, Todo].anyIt( ctx.issues.hasKey(it) and ctx.issues[it].len > 0) let indent = if today and future: " " else: "" # Today's items if today: if future: ctx.writeHeader("Today") for s in [Current, TodoToday, Pending]: if ctx.issues.hasKey(s) and ctx.issues[s].len > 0: let visibleIssues = ctx.issues[s].filterIt( showHidden or not (it.hasProp("hide-until") and it.getDateTime("hide-until") > getTime().local)) stdout.write ctx.formatSection(visibleIssues, s, indent, verbose) # 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: let visibleIssues = ctx.issues[s].filterIt( showHidden or not (it.hasProp("hide-until") and it.getDateTime("hide-until") > getTime().local)) stdout.write ctx.formatSection(visibleIssues, s, indent, verbose) trace "listing complete" when isMainModule: try: let consoleLogger = newConsoleLogger( levelThreshold=lvlInfo, fmtStr="pit - $levelname: ") logging.addHandler(consoleLogger) # Parse arguments let args = docopt(USAGE, version = PIT_VERSION) if args["--debug"]: consoleLogger.levelThreshold = lvlDebug if args["--echo-args"]: stderr.writeLine($args) if args["help"]: stderr.writeLine(USAGE & "\n") stderr.writeLine(ONLINE_HELP) quit() let ctx = initContext(args) trace "context initiated" var propertiesOption = none(TableRef[string,string]) var tagsOption = none(seq[string]) if args["--properties"] or args["--context"]: var props = if args["--properties"]: parsePropertiesOption($args["--properties"]) else: newTable[string,string]() 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 = if args[""]: parseEnum[IssueState]($args[""]) else: TodoToday var issueProps = propertiesOption.get(newTable[string,string]()) if not issueProps.hasKey("created"): issueProps["created"] = getTime().local.formatIso8601 if not issueProps.hasKey("context") and ctx.defaultContext.isSome(): stderr.writeLine("Using default context: " & ctx.defaultContext.get) issueProps["context"] = ctx.defaultContext.get var issue = Issue( id: genUUID(), summary: $args[""], properties: issueProps, tags: if args["--tags"]: ($args["--tags"]).split(",").mapIt(it.strip) else: newSeq[string]()) ctx.tasksDir.store(issue, state) stdout.writeLine ctx.formatIssue(issue) elif args["reorder"]: ctx.reorder(parseEnum[IssueState]($args[""])) elif args["edit"]: for editRef in @(args[""]): var stateOption = none(IssueState) try: stateOption = some(parseEnum[IssueState](editRef)) except: discard if stateOption.isSome: let state = stateOption.get ctx.loadIssues(state) for issue in ctx.issues[state]: edit(issue) else: edit(ctx.tasksDir.loadIssueById(editRef)) elif args["tag"]: if not args["--tags"]: raise newException(Exception, "no tags given") let newTags = ($args["--tags"]).split(",").mapIt(it.strip) for id in @(args[""]): var issue = ctx.tasksDir.loadIssueById(id) issue.tags = deduplicate(issue.tags & newTags) issue.store() elif args["untag"]: let tagsToRemove: seq[string] = if args["--tags"]: ($args["--tags"]).split(",").mapIt(it.strip) else: @[] for id in @(args[""]): var issue = ctx.tasksDir.loadIssueById(id) if tagsToRemove.len > 0: issue.tags = issue.tags.filter( proc (tag: string): bool = not tagsToRemove.anyIt(it == tag)) else: issue.tags = @[] issue.store() 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["todo-today"]: targetState = TodoToday elif args["pending"]: targetState = Pending elif args["start"]: targetState = Current elif args["todo"]: targetState = Todo elif args["suspend"]: targetState = Dormant for id in @(args[""]): var issue = ctx.tasksDir.loadIssueById(id) if propertiesOption.isSome: for k,v in propertiesOption.get: issue[k] = v if targetState == Done: issue["completed"] = getTime().local.formatIso8601 if issue.hasProp("recurrence") and issue.getRecurrence.isSome: let nextIssue = ctx.tasksDir.nextRecurrence(issue.getRecurrence.get, issue) ctx.tasksDir.store(nextIssue, Todo) info "created the next recurrence:" stdout.writeLine ctx.formatIssue(nextIssue) issue.changeState(ctx.tasksDir, targetState) if ctx.triggerPtk or args["--ptk"]: if targetState == Current: let issue = ctx.tasksDir.loadIssueById($(args[""][0])) var cmd = "ptk start" if issue.tags.len > 0 or issue.hasProp("context"): let tags = concat( issue.tags, if issue.hasProp("context"): @[issue.properties["context"]] else: @[] ) cmd &= " -g \"" & tags.join(",") & "\"" cmd &= " -n \"pit-id: " & $issue.id & "\"" cmd &= " \"" & issue.summary & "\"" discard execShellCmd(cmd) elif targetState == Done or targetState == Pending: discard execShellCmd("ptk stop") elif args["hide-until"]: let issue = ctx.tasksDir.loadIssueById($(args[""])) issue.setDateTime("hide-until", parseDate($args[""])) issue.store() elif args["delegate"]: let issue = ctx.tasksDir.loadIssueById($(args[""])) issue["delegated-to"] = $args[""] issue.store() elif args["delete"] or args["rm"]: for id in @(args[""]): let issue = ctx.tasksDir.loadIssueById(id) if not args["--yes"]: stderr.write("Delete '" & issue.summary & "' (y/n)? ") if not "yes".startsWith(stdin.readLine.toLower): continue issue.delete 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) # 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 args["--tags"]: filter.hasTags = ($args["--tags"]).split(',') 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 statesOption = none(seq[IssueState]) var issueIdsOption = none(seq[string]) if args["contexts"]: listContexts = true elif args[""]: try: statesOption = some(args[""].mapIt(parseEnum[IssueState]($it))) except: issueIdsOption = some(args[""].mapIt($it)) # 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"]) let maxLen = foldl(uniqContexts, if a.len > b.len: a else: b ).len for c in uniqContexts.sorted: stdout.writeLine(c.alignLeft(maxLen+2) & ctx.getIssueContextDisplayName(c)) # List a specific issue elif issueIdsOption.isSome: for issueId in issueIdsOption.get: let issue = ctx.tasksDir.loadIssueById(issueId) stdout.writeLine ctx.formatIssue(issue) # List all issues else: trace "listing all issues" let showBoth = args["--today"] == args["--future"] ctx.list( filter = filterOption, states = statesOption, showToday = showBoth or args["--today"], showFuture = showBoth or args["--future"], showHidden = args["--show-hidden"], verbose = ctx.verbose) elif args["add-binary-property"]: let issue = ctx.tasksDir.loadIssueById($(args[""])) let propIn = if $(args[""]) == "-": stdin else: open($(args[""])) try: issue[$(args[""])] = encodeAsDataUri(readAll(propIn)) finally: close(propIn) issue.store() elif args["get-binary-property"]: let issue = ctx.tasksDir.loadIssueById($(args[""])) if not issue.hasProp($(args[""])): raise newException(Exception, "issue " & ($issue.id)[0..<6] & " has no property name '" & $(args[""]) & "'") let propOut = if $(args[""]) == "-": stdout else: open($(args[""]), fmWrite) try: write(propOut, decodeDataUri(issue[$(args[""])])) finally: close(propOut) except: fatal getCurrentExceptionMsg() #raise getCurrentException() quit(QuitFailure)