diff --git a/pit.nimble b/pit.nimble index f98dfcb..0d91962 100644 --- a/pit.nimble +++ b/pit.nimble @@ -1,6 +1,6 @@ # Package -version = "4.14.0" +version = "4.16.0" author = "Jonathan Bernard" description = "Personal issue tracker." license = "MIT" diff --git a/src/online-help.txt b/src/online-help.txt new file mode 100644 index 0000000..663289e --- /dev/null +++ b/src/online-help.txt @@ -0,0 +1,93 @@ +Issue States: + + PIT organizes issues around their state, which is one of: + + current - issues actively being worked + todo-today - issues planned for today + pending - issues that are blocked by some third-party + done - issues that have been completely resolved + todo - issues that need to be done in the future + dormant - issues that are low-priority, to be tracked, but hidden + by default + +Issue Properties: + + PIT supports adding arbitrary properties to any issue to track any metadata + about the issue the user may wish. There are several properties that have + special behavior attached to them. They are: + + created + + If present, expected to be an ISO 8601-formatted date that represents the + time when the issue was created. + + completed + + If present, expected to be an ISO 8601-formatted date that represents the + time when the issue moved to the "done" state. PIT will add this + property automatically when you use the "done" command, and can filter on + this value. + + context + + Allows issues to be organized into contexts. The -c option is short-hand + for '-p context:' and the 'list contexts' command will show + all values of 'context' set in existing issues. + + delegated-to + + When an issue now belongs to someone else, but needs to be monitored for + completion, this allows you to keep the issue in its current state but + note how it has been delegated. When present PIT will prepend this value + to the issue summary with an accent color. + + hide-until + + When present, expected to be an ISO 8601-formatted date and used to + supress the display of the issue until on or after the given date. + + pending + + When an issue is blocked by a third party, this property can be used to + capture details about the dependency When present PIT will display this + value after the issue summary. + + recurrence + + When an issue is moved to the "done" state, if the issue has a valid + "recurrence" property, PIT will create a new issue and set the + "hide-until" property for that new issue depending on the recurrence + definition. + + A valid recurrence value has a time value and optionally has an source + issue ID. For example: + + every 5 days, 10a544 + + The first word, "every", is expected to be either "every" or "after". + + The second portion is expected to be a time period. Supported time units + are "hour", "day", "week", "month", an "year", along with the plural + forms (e.g. "5 days", "8 hours", etc.). + + The final portion is the source issue ID. This is optional. When a source + issue ID is given, the new issue is created as a clone of the given + issue. When not given, the issue being closed is used for cloning. + + The "every" and "after" keywords allow the user to choose whether the new + issue is created based on the creation time ("every") or the completion + time ("after") of the issue being closed based. + + Examples: + + every day + every 2 days + after 2 days + every week + after 12 hours + every 2 weeks, 10a544 + + tags + + If present, expected to be a comma-delimited list of text tags. The -g + option is a short-hand for '-p tags:'. diff --git a/src/pit.nim b/src/pit.nim index faa8194..9419c2a 100644 --- a/src/pit.nim +++ b/src/pit.nim @@ -149,6 +149,10 @@ proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState, 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) @@ -192,13 +196,14 @@ proc edit(issue: Issue) = let editedIssue = loadIssue(issue.filepath) editedIssue.store() except: - fatal "pit: updated issue is invalid (ignoring edits): \n\t" & + 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, 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) @@ -207,10 +212,13 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], states: Option[seq[Issue it.hasProp("completed") and sameDay(getTime().local, it.getDateTime("completed"))) stdout.write ctx.formatSection(ctx.issues[state], state, "", verbose) + trace "listing complete" return - ctx.loadAllIssues() - if filter.isSome: ctx.filterIssues(filter.get) + 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) @@ -240,146 +248,35 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], states: Option[seq[Issue stdout.write ctx.formatSection(visibleIssues, s, indent, verbose) + trace "listing complete" + when isMainModule: - try: - let usage = """ -Usage: - pit ( new | add) [] [options] - pit list contexts [options] - pit list [...] [options] - pit ( start | done | pending | todo-today | todo | suspend ) ... [options] - pit edit ... [options] - pit tag ... [options] - pit untag ... [options] - pit reorder [options] - pit delegate - pit hide-until [options] - pit ( delete | rm ) ... [options] - pit add-binary-property [options] - pit get-binary-property [options] + const usage = readFile("src/usage.txt") + const onlineHelp = readFile("src/online-help.txt") -Options: - - -h, --help Print this usage and help information. - - -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. - - -c, --context Shorthand for '-p context:' - - -g, --tags Specify tags for an issue. - - -T, --today Limit to today's issues. - - -F, --future Limit to future issues. - - -m, --match Limit to issues whose summaries match the given - pattern (PCRE regex supported). - - -M, --match-all Limit to the issues whose summaries or details - match the given pattern (PCRE regex supported). - - -v, --verbose Show issue details when listing issues. - - -q, --quiet Suppress verbose output. - - -y, --yes Automatically answer "yes" to any prompts. - - -C, --config Location of the config file (defaults to $HOME/.pitrc) - - -E, --echo-args Echo arguments (for debug purposes). - - -d, --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. - - --ptk Enable PTK integration for this command. - -""" - - let onlineHelp = """ -Issue States - - PIT organizes issues around their state, which is one of: - - current - issues actively being worked - todo-today - issues planned for today - pending - issues that are blocked by some third-party - done - issues that have been completely resolved - todo - issues that need to be done in the future - dormant - issues that are low-priority, to be tracked, but hidden - by default - -Issue Properties - - PIT supports adding arbitrary properties to any issue to track any metadata - about the issue the user may wish. There are several properties that have - special behavior attached to them. They are: - - created - - If present, expected to be an ISO 8601-formatted date that represents the - time when the issue was created. - - completed - - If present, expected to be an ISO 8601-formatted date that represents the - time when the issue moved to the "done" state. PIT will add this - property automatically when you use the "done" command, and can filter on - this value. - - context - - Allows issues to be organized into contexts. The -c option is short-hand - for '-p context:' and the 'list contexts' command will show - all values of 'context' set in existing issues. - - delegated-to - - When an issue now belongs to someone else, but needs to be monitored for - completion, this allows you to keep the issue in its current state but - note how it has been delegated. When present PIT will prepend this value - to the issue summary with an accent color. - - hide-until - - When present, expected to be an ISO 8601-formatted date and used to - supress the display of the issue until on or after the given date. - - pending - - When an issue is blocked by a third party, this property can be used to - capture details about the dependency When present PIT will display this - value after the issue summary. - - recurrence - - TODO, not yet implemented. - - tags - - If present, expected to be a comma-delimited list of text tags. The -g - option is a short-hand for '-p tags:'. -""" - - logging.addHandler(newConsoleLogger()) + let consoleLogger = newConsoleLogger( + levelThreshold=lvlInfo, + fmtStr="$app - $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) + if args["help"]: + stderr.writeLine(usage & "\n") stderr.writeLine(onlineHelp) quit() let ctx = initContext(args) + trace "context initiated" + var propertiesOption = none(TableRef[string,string]) var tagsOption = none(seq[string]) @@ -476,17 +373,22 @@ Issue Properties if propertiesOption.isSome: for k,v in propertiesOption.get: issue[k] = v - if targetState == Done: issue["completed"] = getTime().local.formatIso8601 + 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) + 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.properties.hasKey("context"): + if issue.tags.len > 0 or issue.hasProp("context"): let tags = concat( issue.tags, - if issue.properties.hasKey("context"): @[issue.properties["context"]] + if issue.hasProp("context"): @[issue.properties["context"]] else: @[] ) cmd &= " -g \"" & tags.join(",") & "\"" @@ -591,6 +493,7 @@ Issue Properties # List all issues else: + trace "listing all issues" let showBoth = args["--today"] == args["--future"] ctx.list(filterOption, statesOption, showBoth or args["--today"], showBoth or args["--future"], @@ -624,6 +527,6 @@ Issue Properties finally: close(propOut) except: - fatal "pit: " & getCurrentExceptionMsg() + fatal getCurrentExceptionMsg() #raise getCurrentException() quit(QuitFailure) diff --git a/src/pitpkg/private/libpit.nim b/src/pitpkg/private/libpit.nim index 07ea095..a54f0cd 100644 --- a/src/pitpkg/private/libpit.nim +++ b/src/pitpkg/private/libpit.nim @@ -1,7 +1,7 @@ import cliutils, docopt, json, logging, langutils, options, os, - sequtils, strutils, tables, times, timeutils, uuids + sequtils, strformat, strutils, tables, times, timeutils, uuids -from nre import find, match, re, Regex +import nre except toSeq type Issue* = ref object @@ -30,9 +30,28 @@ type contexts*: TableRef[string, string] cfg*: CombinedConfig + Recurrence* = object + cloneId*: Option[string] + interval*: TimeInterval + isFromCompletion*: bool + 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" +let RECURRENCE_PATTERN = re"(every|after) ((\d+) )?((hour|day|week|month|year)s?)(, ([0-9a-fA-F]+))?" + +let traceStartTime = cpuTime() +var lastTraceTime = traceStartTime + +proc trace*(msg: string, diffFromLast = false) = + let curTraceTime = cpuTime() + + if diffFromLast: + debug &"{(curTraceTime - lastTraceTime) * 1000:6.2f}ms {msg}" + else: + debug &"{cpuTime() - traceStartTime:08.4f} {msg}" + + lastTraceTime = curTraceTime proc displayName*(s: IssueState): string = case s @@ -64,6 +83,30 @@ proc getDateTime*(issue: Issue, key: string, default: DateTime): DateTime = proc setDateTime*(issue: Issue, key: string, dt: DateTime) = issue.properties[key] = dt.formatIso8601 +proc getRecurrence*(issue: Issue): Option[Recurrence] = + if not issue.hasProp("recurrence"): return none[Recurrence]() + + let m = issue["recurrence"].match(RECURRENCE_PATTERN) + + if not m.isSome: + warn "not a valid recurrence value: '" & issue["recurrence"] & "'" + return none[Recurrence]() + + let c = nre.toSeq(m.get.captures) + let timeVal = if c[2].isSome: c[2].get.parseInt + else: 1 + return some(Recurrence( + isFromCompletion: c[0].get == "after", + interval: + case c[4].get: + of "hour": hours(timeVal) + of "day": days(timeVal) + of "week": weeks(timeVal) + of "month": months(timeVal) + of "year": years(timeVal) + else: weeks(1), + cloneId: c[6])) + ## Issue filtering proc initFilter*(): IssueFilter = result = IssueFilter( @@ -221,6 +264,8 @@ proc storeOrder*(issues: seq[Issue], path: string) = proc loadIssues*(path: string): seq[Issue] = let orderFile = path / "order.txt" + trace "loading issues under " & path + let orderedIds = if fileExists(orderFile): toSeq(orderFile.lines) @@ -236,6 +281,7 @@ proc loadIssues*(path: string): seq[Issue] = if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome(): unorderedIssues.add((loadIssue(path), false)) + trace "loaded " & $unorderedIssues.len & " issues", true result = @[] # Add all ordered issues in order @@ -250,6 +296,8 @@ proc loadIssues*(path: string): seq[Issue] = if taggedIssue.ordered: continue result.add(taggedIssue.issue) + trace "ordered the loaded issues", true + # Finally, save current order result.storeOrder(path) @@ -261,6 +309,37 @@ proc changeState*(issue: Issue, tasksDir: string, newState: IssueState) = proc delete*(issue: Issue) = removeFile(issue.filepath) +proc nextRecurrence*(tasksDir: string, rec: Recurrence, defaultIssue: Issue): Issue = + let baseIssue = if rec.cloneId.isSome: tasksDir.loadIssueById(rec.cloneId.get) + else: defaultIssue + + let newProps = newTable[string,string]() + for k, v in baseIssue.properties: + if k != "created" and k != "completed": + newProps[k] = v + + result = Issue( + id: genUUID(), + summary: baseIssue.summary, + properties: newProps, + tags: baseIssue.tags) + + let now = getTime().local + + let startDate = + if rec.isFromCompletion: + if baseIssue.hasProp("completed"): baseIssue.getDateTime("completed") + else: now + else: + if baseIssue.hasProp("created"): baseIssue.getDateTime("created") + else: now + + # walk the calendar until the next recurrence that is after the current time. + var nextTime = startDate + rec.interval + while now > nextTime: nextTime += rec.interval + + result.setDateTime("hide-until", nextTime) + ## Utilities for working with issue collections. proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] = result = issues @@ -295,14 +374,14 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo foldl(pitrcLocations, if len(a) > 0: a elif fileExists(b): b else: "") if not fileExists(pitrcFilename): - warn "pit: could not find .pitrc file: " & pitrcFilename + warn "could not find .pitrc file: " & pitrcFilename if isEmptyOrWhitespace(pitrcFilename): pitrcFilename = $getEnv("HOME") & "/.pitrc" var cfgFile: File try: cfgFile = open(pitrcFilename, fmWrite) cfgFile.write("{\"tasksDir\": \"/path/to/tasks\"}") - except: warn "pit: could not write default .pitrc to " & pitrcFilename + except: warn "could not write default .pitrc to " & pitrcFilename finally: close(cfgFile) var cfgJson: JsonNode diff --git a/src/pitpkg/version.nim b/src/pitpkg/version.nim index 1e3a191..c1e61d6 100644 --- a/src/pitpkg/version.nim +++ b/src/pitpkg/version.nim @@ -1 +1 @@ -const PIT_VERSION* = "4.15.0" +const PIT_VERSION* = "4.16.0" \ No newline at end of file diff --git a/src/usage.txt b/src/usage.txt new file mode 100644 index 0000000..4c1a955 --- /dev/null +++ b/src/usage.txt @@ -0,0 +1,57 @@ +Usage: + pit ( new | add) [] [options] + pit list contexts [options] + pit list [...] [options] + pit ( start | done | pending | todo-today | todo | suspend ) ... [options] + pit edit ... [options] + pit tag ... [options] + pit untag ... [options] + pit reorder [options] + pit delegate + pit hide-until [options] + pit ( delete | rm ) ... [options] + pit add-binary-property [options] + pit get-binary-property [options] + pit help + +Options: + + -h, --help Print this usage and help information. + + -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. + + -c, --context Shorthand for '-p context:' + + -g, --tags Specify tags for an issue. + + -T, --today Limit to today's issues. + + -F, --future Limit to future issues. + + -m, --match Limit to issues whose summaries match the given + pattern (PCRE regex supported). + + -M, --match-all Limit to the issues whose summaries or details + match the given pattern (PCRE regex supported). + + -v, --verbose Show issue details when listing issues. + + -q, --quiet Suppress verbose output. + + -y, --yes Automatically answer "yes" to any prompts. + + -C, --config Location of the config file (defaults to $HOME/.pitrc) + + -E, --echo-args Echo arguments (for debug purposes). + + -d, --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. + + --ptk Enable PTK integration for this command. + + --debug Enable debug-level log output.