## Personal Issue Tracker CLI interface ## ==================================== import std/[algorithm, logging, options, os, sequtils, tables, times, unicode] 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] 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]] = "true" 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) let val = if pair.len == 1: "true" else: pair[1] 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. ctx.loadIssues(state) discard os.execShellCmd(EDITOR & " '" & (ctx.cfg.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 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() var filterOption = none(IssueFilter) 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 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 = 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 tagsOption.isSome: tagsOption.get else: newSeq[string]()) ctx.cfg.tasksDir.store(issue, state) updatedIssues.add(issue) stdout.writeLine formatIssue(issue) elif args["reorder"]: ctx.reorder(parseEnum[IssueState]($args[""])) elif args["edit"]: for editRef in @(args[""]): let propsOption = if args["--properties"]: some(parsePropertiesOption($args["--properties"])) else: none(TableRef[string, string]) 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 propsOption.isSome: for k,v in propsOption.get: issue[k] = v 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 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["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["--yes"]: 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 statesOption = none(seq[IssueState]) var issueIdsOption = none(seq[string]) if args["contexts"]: listContexts = 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() if filterOption.isSome: ctx.filterIssues(filterOption.get) 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 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.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["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" 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)