## Personal Issue Tracker CLI interface ## ==================================== import std/[algorithm, logging, options, os, sequtils, sets, tables, terminal, times, unicode] import cliutils, data_uri, docopt, json, timeutils, uuids, zero_functional from nre import match, re import strutils except alignLeft, capitalize, strip, toUpper, toLower import pit/[cliconstants, formatting, libpit, projects, sync_pbm_vsb] export formatting, libpit let EDITOR = if existsEnv("EDITOR"): getEnv("EDITOR") else: "vi" 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]] = MATCH_ANY else: result[pair[0]] = pair[1] proc parseExclPropertiesOption(propsOpt: string): TableRef[string, seq[string]] = result = newTable[string, seq[string]]() for propText in propsOpt.split(";"): let pair = propText.split(":", 1) if not result.hasKey(pair[0]): result[pair[0]] = @[] if pair.len == 2: result[pair[0]].add(pair[1]) 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.cfg.tasksDir / $state / "order.txt") & "' /dev/tty") proc addIssue( ctx: CliContext, args: Table[string, Value], propertiesOption = none[TableRef[string, string]](), tagsOption = none[seq[string]]()): Issue = 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 if not args["--non-interactive"]: # look for default properties for this context let globalDefaultProps = if ctx.cfg.defaultPropertiesByContext.hasKey(""): ctx.cfg.defaultPropertiesByContext[""] else: newSeq[string]() let contextDefaultProps = if issueProps.hasKey("context") and ctx.cfg.defaultPropertiesByContext.hasKey(issueProps["context"]): ctx.cfg.defaultPropertiesByContext[issueProps["context"]] else: newSeq[string]() let defaultProps = toOrderedSet(globalDefaultProps & contextDefaultProps) if defaultProps.len > 0: ctx.loadAllIssues() if issueProps.hasKey("context"): ctx.filterIssues(propsFilter(newTable({"context": issueProps["context"]}))) let numberRegex = re("^[0-9]+$") for propName in defaultProps: if not issueProps.hasKey(propName): let allIssues: seq[seq[Issue]] = toSeq(values(ctx.issues)) let previousValues = toSeq(toHashSet(allIssues --> flatten() .filter(it.hasProp(propName)) .map(it[propName]))) let idxValPairs: seq[tuple[key: int, val: string]] = toSeq(pairs(previousValues)) let previousValuesDisplay: seq[string] = idxValPairs --> map(" " & $it[0] & " - " & it[1]) stdout.write( "Previous values for property '" & propName & "':\p" & previousValuesDisplay.join("\p") & "\p" & "Do you want to set a value for '" & propName & "'? " & "You can use the numbers above to use an existing value, enter " & "something new, or leave blank to indicate no value.\p" & color(propName, fg=cMagenta) & ":" & ansiEscSeq(fg=cBrightBlue) & " ") let resp = stdin.readLine.strip let numberResp = resp.match(numberRegex) if numberResp.isSome: let idx = parseInt(resp) if idx >= 0 and idx < previousValues.len: issueProps[propName] = previousValues[idx] elif resp.len > 0: issueProps[propName] = resp stdout.writeLine(RESET_FORMATTING) result = Issue( id: genUUID(), summary: $args[""], properties: issueProps, tags: if tagsOption.isSome: tagsOption.get else: newSeq[string]()) ctx.cfg.tasksDir.store(result, state) stdout.writeLine "\p" & formatIssue(result) 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 CatchableError: fatal "updated issue is invalid (ignoring edits): \n\t" & getCurrentExceptionMsg() issue.store() 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["--silent"]: consoleLogger.levelThreshold = lvlNone if args["--echo-args"]: stderr.writeLine($args) if args["help"]: stderr.writeLine(USAGE & "\p") stderr.writeLine(ONLINE_HELP) quit() let ctx = initContext(args) 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() 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["--excl-properties"] or args["--excl-context"]: var exclProps = if args["--excl-properties"]: parseExclPropertiesOption($args["--excl-properties"]) else: newTable[string,seq[string]]() if args["--excl-context"]: if not exclProps.hasKey("context"): exclProps["context"] = @[] let exclContexts = split($args["--excl-context"], ",") exclProps["context"] = exclProps["context"].concat(exclContexts) exclPropsOption = some(exclProps) if args["--tags"]: tagsOption = some(($args["--tags"]).split(",").mapIt(it.strip)) 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 # Add property exclusions (if given) if exclPropsOption.isSome: filter.exclProperties = exclPropsOption.get # If they supplied text matches, add that to the filter. if args["--match"]: filter.summaryMatch = some(re("(?i)" & $args["--match"])) if args["--match-all"]: filter.fullMatch = some(re("(?i)" & $args["--match-all"])) # 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 if tagsOption.isSome: filter.hasTags = tagsOption.get if exclTagsOption.isSome: filter.exclTags = exclTagsOption.get if args["--today"]: filter.inclStates.add(@[Current, TodoToday, Pending]) if args["--future"]: filter.inclStates.add(@[Pending, Todo]) if args["--show-hidden"]: filter.exclHidden = false # 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"]: updatedIssues.add(ctx.addIssue(args, propertiesOption, tagsOption)) 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 CatchableError: discard if stateOption.isSome: let state = stateOption.get ctx.loadIssues(state) for issue in ctx.issues[state]: if propertiesOption.isSome: for k,v in propertiesOption.get: issue[k] = v if tagsOption.isSome: issue.tags = deduplicate(issue.tags & tagsOption.get) if exclTagsOption.isSome: issue.tags = issue.tags.filter( proc (tag: string): bool = not exclTagsOption.get.anyIt(it == tag)) if args["--non-interactive"]: issue.store() else: edit(issue) updatedIssues.add(issue) else: let issue = ctx.cfg.tasksDir.loadIssueById(editRef) if propertiesOption.isSome: for k,v in propertiesOption.get: issue[k] = v if tagsOption.isSome: issue.tags = deduplicate(issue.tags & tagsOption.get) if exclTagsOption.isSome: issue.tags = issue.tags.filter( proc (tag: string): bool = not exclTagsOption.get.anyIt(it == tag)) if args["--non-interactive"]: issue.store() else: edit(issue) updatedIssues.add(issue) elif args["tag"]: if tagsOption.isNone: raise newException(Exception, "no tags given") let newTags = tagsOption.get for id in @(args[""]): 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] = if tagsOption.isSome: tagsOption.get else: @[] for id in @(args[""]): var issue = ctx.cfg.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() updatedIssues.add(issue) elif args["update-details"]: let details = if not args["--file"] or $args["--file"] == "-": readAll(stdin) else: readFile($args["--file"]) for id in @(args[""]): var issue = ctx.cfg.tasksDir.loadIssueById(id) issue.details = details issue.store() updatedIssues.add(issue) 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.cfg.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.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: let issue = ctx.cfg.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.id)[0..<6] & "] " & issue.summary & "\"" discard execShellCmd(cmd) elif targetState == Done or targetState == Pending: discard execShellCmd("ptk stop") elif args["hide-until"]: let issue = ctx.cfg.tasksDir.loadIssueById($(args[""])) issue.setDateTime("hide-until", parseDate($args[""])) issue.store() updatedIssues.add(issue) elif args["delegate"]: let issue = ctx.cfg.tasksDir.loadIssueById($(args[""])) issue["delegated-to"] = $args[""] issue.store() updatedIssues.add(issue) elif args["delete"] or args["rm"]: for id in @(args[""]): let issue = ctx.cfg.tasksDir.loadIssueById(id) if not args["--non-interactive"]: stderr.write("Delete '" & issue.summary & "' (y/n)? ") if not "yes".startsWith(stdin.readLine.toLower): continue issue.delete updatedIssues.add(issue) elif args["list"]: 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[""]: try: statesOption = some(args[""]. mapIt(parseEnum[IssueState]($it))) except CatchableError: 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)) elif listTags: var uniqTags = newseq[string]() if statesOption.isSome: for state in statesOption.get: ctx.loadIssues(state) else: ctx.loadAllIssues() ctx.filterIssues(filter) for state, issueList in ctx.issues: for issue in issueList: for tag in issue.tags: if not uniqTags.contains(tag): uniqTags.add(tag) stdout.writeLine(uniqTags.sorted.join("\n")) # List a specific issue elif issueIdsOption.isSome: for issueId in issueIdsOption.get: let issue = ctx.cfg.tasksDir.loadIssueById(issueId) stdout.writeLine formatIssue(issue) # List projects elif listProjects: ctx.listProjects(some(filter)) # List milestones elif listMilestones: ctx.listMilestones(some(filter)) # List all issues else: trace "listing all issues" let showBoth = args["--today"] == args["--future"] ctx.list( filter = some(filter), states = statesOption, showToday = showBoth or args["--today"], showFuture = showBoth or args["--future"], verbose = ctx.verbose) elif args["show"]: if args["project-board"]: if not args["--show-done"]: filter.exclStates.add(Done) ctx.showProjectBoard(some(filter)) 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[""].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[""])) let propIn = if $(args[""]) == "-": stdin else: open($(args[""])) try: issue[$(args[""])] = encodeAsDataUri(readAll(propIn)) finally: close(propIn) issue.store() updatedIssues.add(issue) elif args["get-binary-property"]: let issue = ctx.cfg.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) elif args["sync"]: if ctx.cfg.syncTargets.len == 0: info "No sync targets configured" for syncTarget in ctx.cfg.syncTargets: let syncCtx = initSyncContext(ctx.cfg, syncTarget) 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() #raise getCurrentException() quit(QuitFailure)