diff --git a/.gitignore b/.gitignore index c1876b3..c2e9046 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,2 @@ -release/ *.sw* -*/build/ -pit-cli/lib/libpit-*.jar +nimcache/ diff --git a/pit b/pit new file mode 100755 index 0000000..48368d3 Binary files /dev/null and b/pit differ diff --git a/pit.nimble b/pit.nimble new file mode 100644 index 0000000..b08d5a7 --- /dev/null +++ b/pit.nimble @@ -0,0 +1,12 @@ +# Package + +version = "4.0.0" +author = "Jonathan Bernard" +description = "Personal issue tracker." +license = "MIT" +srcDir = "src" +bin = @["pit"] + +# Dependencies + +requires @["nim >= 0.18.1", "uuids 0.1.9", "docopt 0.6.5", "cliutils 0.3.2"] diff --git a/src/pit.nim b/src/pit.nim new file mode 100644 index 0000000..a1d7054 --- /dev/null +++ b/src/pit.nim @@ -0,0 +1,203 @@ +## Personal Issue Tracker +## ====================== +## + +import cliutils, docopt, json, logging, os, ospaths, sequtils, strutils, + tables, times, uuids + +import pit/private/libpit +export libpit + +type + CliContext = ref object + tasksDir*: string + contexts*: TableRef[string, string] + issues*: TableRef[IssueState, seq[Issue]] + cfg*: CombinedConfig + +proc initContext(args: Table[string, Value]): CliContext = + let pitrcLocations = @[ + if args["--config"]: $args["--config"] else: "", + ".pitrc", $getEnv("PITRC"), $getEnv("HOME") & "/.pitrc"] + + var pitrcFilename: string = + foldl(pitrcLocations, if len(a) > 0: a elif existsFile(b): b else: "") + + if not existsFile(pitrcFilename): + warn "pit: could not find .pitrc file: " & pitrcFilename + if isNilOrWhitespace(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 + finally: close(cfgFile) + + var cfgJson: JsonNode + try: cfgJson = parseFile(pitrcFilename) + except: raise newException(IOError, + "unable to read config file: " & pitrcFilename & + "\x0D\x0A" & getCurrentExceptionMsg()) + + let cfg = CombinedConfig(docopt: args, json: cfgJson) + + result = CliContext( + cfg: cfg, + tasksDir: cfg.getVal("tasks-dir", ""), + contexts: newTable[string,string](), + issues: newTable[IssueState, seq[Issue]]()) + + if cfgJson.hasKey("contexts"): + for k, v in cfgJson["contexts"]: + result.contexts[k] = v.getStr() + + if isNilOrWhitespace(result.tasksDir): + raise newException(Exception, "no tasks directory configured") + + if not existsDir(result.tasksDir): + 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() + return ctx.contexts[context] + +proc formatIssue(ctx: CliContext, issue: Issue, state: IssueState, + width: int, indent: string, topPadded: bool): string = + var showDetails = not issue.details.isNilOrWhitespace + var lines: seq[string] = @[] + + if showDetails and not topPadded: lines.add("") + + var wrappedSummary = issue.summary.wordWrap(width - 2).indent(2) + wrappedSummary = "*" & wrappedSummary[1..^1] + lines.add(wrappedSummary.indent(indent.len)) + + if state == Pending and issue.properties.hasKey("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) + + if showDetails: lines.add(issue.details.indent(indent.len + 2)) + + return lines.join("\n") + +proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState, + width = 80, indent = " "): string = + let innerWidth = width - (indent.len * 2) + var lines: seq[string] = @[] + + lines.add(indent & ".".repeat(innerWidth)) + lines.add(state.displayName.center(width)) + lines.add("") + + var topPadded = true + var showDetails = false + + let issuesByContext = issues.groupBy("context") + + if issues.len > 5 and issuesByContext.len > 1: + for context, ctxIssues in issuesByContext: + lines.add(indent & ctx.getIssueContextDisplayName(context) & ":") + lines.add("") + + for i in ctxIssues: + lines.add(ctx.formatIssue(i, state, innerWidth - 2, indent & " ", topPadded)) + topPadded = not i.details.isNilOrWhitespace + + if not topPadded: lines.add("") + + else: + for i in issues: + lines.add(ctx.formatIssue(i, state, innerWidth, indent, topPadded)) + topPadded = not i.details.isNilOrWhitespace + + lines.add("") + return lines.join("\n") + +proc loadIssues(ctx: CliContext, state: IssueState): seq[Issue] = + result = loadIssues(joinPath(ctx.tasksDir, $state)) + +proc loadAllIssues(ctx: CliContext) = + for state in IssueState: + ctx.issues[state] = loadIssues(ctx, state) + +proc sameDay(a, b: DateTime): bool = + result = a.year == b.year and a.yearday == b.yearday + +when isMainModule: + + try: + let doc = """ +Usage: + pit new <state> <summary> [options] + pit list [<state>] [options] + pit today + pit start + pit done + pit pending + pit edit + +Options: + + -t, --tags <tags> Specify tags for an issue. + -p, --properties <props> Specify properties for an issue. Formatted as "key:val;key:val" + -C, --config <cfgFile> Location of the config file (defaults to $HOME/.pitrc) + -h, --help Print this usage information. + -T, --today Limit to today's issues. + -E, --echo-args Echo arguments (for debug purposes). + --tasks-dir Path to the tasks directory (defaults to the value + configured in the .pitrc file) +""" + + logging.addHandler(newConsoleLogger()) + + # Parse arguments + let args = docopt(doc, version = "ptk 0.12.1") + + if args["--echo-args"]: echo $args + + if args["--help"]: + echo doc + quit() + + let now = getTime().local + + let ctx = initContext(args) + + ## Actual command runners + if args["list"]: + + ctx.loadAllIssues() + + let fullWidth = 80 + let innerWidth = fullWidth - 4 + + # Today's items + echo '_'.repeat(fullWidth) + echo "Today".center(fullWidth) + echo '~'.repeat(fullWidth) + echo "" + + for s in [Current, TodoToday]: + echo ctx.formatSection(ctx.issues[s], s) + + echo ctx.formatSection( + ctx.issues[Done].filterIt( + it.properties.hasKey("completed") and + sameDay(now, it.getDateTime("completed"))), Done) + + # Future items + echo '_'.repeat(fullWidth) + echo "Future".center(fullWidth) + echo '~'.repeat(fullWidth) + echo "" + + for s in [Pending, Todo]: + echo ctx.formatSection(ctx.issues[s], s) + + except: + fatal "pit: " & getCurrentExceptionMsg() + #raise getCurrentException() + quit(QuitFailure) diff --git a/src/pit/private/libpit.nim b/src/pit/private/libpit.nim new file mode 100644 index 0000000..7e89651 --- /dev/null +++ b/src/pit/private/libpit.nim @@ -0,0 +1,120 @@ +import cliutils, options, os, ospaths, sequtils, strutils, tables, times, uuids + +from nre import re, match +type + Issue* = ref object + id*: UUID + filepath*: string + summary*, details*: string + properties*: TableRef[string, string] + tags*: seq[string] + + IssueState* = enum + Current = "current", + TodoToday = "todo-today", + Pending = "pending", + Done = "done", + Todo = "todo" + +const ISO8601Format* = "yyyy:MM:dd'T'HH:mm:sszzz" +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" + +proc displayName*(s: IssueState): string = + case s + of Current: result = "Current" + of Pending: result = "Pending" + of Done: result = "Done" + of Todo: result = "Todo" + of TodoToday: result = "Todo" + +## Allow issue properties to be accessed as if the issue was a table +proc `[]`*(issue: Issue, key: string): string = + return issue.properties[key] + +proc `[]=`*(issue: Issue, key: string, value: string) = + issue.properties[key] = value + +proc getDateTime*(issue: Issue, key: string): DateTime = + return parse(issue.properties[key], ISO8601Format) + +proc setDateTime*(issue: Issue, key: string, dt: DateTime) = + issue.properties[key] = format(dt, ISO8601Format) + +## Parse and format issues +proc fromStorageFormat*(id: string, issueTxt: string): Issue = + type ParseState = enum ReadingSummary, ReadingProps, ReadingDetails + + result = Issue( + id: parseUUID(id), + properties: newTable[string,string](), + tags: @[]) + + var parseState = ReadingSummary + var detailLines: seq[string] = @[] + + for line in issueTxt.splitLines(): + if line.startsWith("#"): continue # ignore lines starting with '#' + + case parseState + + of ReadingSummary: + result.summary = line.strip() + parseState = ReadingProps + + of ReadingProps: + # Ignore empty lines + if line.isNilOrWhitespace: continue + + # Look for the sentinal to start parsing as detail lines + if line == "--------": + parseState = ReadingDetails + continue + + + let parts = line.split({':'}, 1).mapIt(it.strip()) + if parts.len != 2: + raise newException(ValueError, "unable to parse property line: " & line) + + # Take care of special properties: `tags` + if parts[0] == "tags": result.tags = parts[1].split({','}).mapIt(it.strip()) + else: result[parts[0]] = parts[1] + + of ReadingDetails: + detailLines.add(line) + + result.details = if detailLines.len > 0: detailLines.join("\n") else: "" + +proc toStorageFormat*(issue: Issue): string = + var lines = @[issue.summary] + 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): + 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 loadIssues*(path: string): seq[Issue] = + result = @[] + for path in walkDirRec(path): + if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome(): + result.add(loadIssue(path)) + +## 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: "" + if not result.hasKey(key): result[key] = newSeq[Issue]() + result[key].add(i) + + diff --git a/tests/test1.nim b/tests/test1.nim new file mode 100644 index 0000000..ba13c99 --- /dev/null +++ b/tests/test1.nim @@ -0,0 +1 @@ +doAssert(1 + 1 == 2) diff --git a/tests/test1.nims b/tests/test1.nims new file mode 100644 index 0000000..3bb69f8 --- /dev/null +++ b/tests/test1.nims @@ -0,0 +1 @@ +switch("path", "$projectDir/../src") \ No newline at end of file