diff --git a/pit.nimble b/pit.nimble index 0b0852c..7f79ff4 100644 --- a/pit.nimble +++ b/pit.nimble @@ -1,6 +1,6 @@ # Package -version = "4.27.0" +version = "4.28.0" author = "Jonathan Bernard" description = "Personal issue tracker." license = "MIT" @@ -28,4 +28,4 @@ requires @[ ] task updateVersion, "Update the version of this package.": - exec "update_nim_package_version pit 'src/pitpkg/cliconstants.nim'" \ No newline at end of file + exec "update_nim_package_version pit 'src/pit/cliconstants.nim'" \ No newline at end of file diff --git a/src/config.nims b/src/config.nims new file mode 100644 index 0000000..de09037 --- /dev/null +++ b/src/config.nims @@ -0,0 +1 @@ +switch("define", "ssl") diff --git a/src/pit.nim b/src/pit.nim index 2468ea9..a60bc30 100644 --- a/src/pit.nim +++ b/src/pit.nim @@ -1,204 +1,19 @@ ## Personal Issue Tracker CLI interface ## ==================================== -import std/[algorithm, logging, options, os, sequtils, wordwrap, tables, - terminal, times, unicode] -import cliutils, data_uri, docopt, json, timeutils, uuids +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, libpit] -export libpit +import pit/[cliconstants, formatting, libpit, sync_pbm_vsb] -type - CliContext = ref object - cfg*: PitConfig - contexts*: TableRef[string, string] - defaultContext*: Option[string] - issues*: TableRef[IssueState, seq[Issue]] - termWidth*: int - triggerPtk*, verbose*: bool +export formatting, libpit 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]](), - 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*(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 formatPlainIssueSummary*(issue: Issue): string = - - result = "$#: $# $#" % [ - $issue.state, - ($issue.id)[0..<6], - issue.summary ] - - if issue.hasProp("delegated-to") or issue.hasProp("pending"): - var parts = newSeq[string]() - - if issue.hasProp("delegated-to"): - parts.add("delegated to " & issue["delegated-to"]) - - if issue.hasProp("pending"): - parts.add("pendin: " & issue["pending"]) - - result &= "($#)" % [ parts.join("; ") ] - -proc formatSectionIssue*( - issue: Issue, - width: int = 80, - indent = "", - verbose = false): string = - - result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true) & " " - - let showDetails = not issue.details.isEmptyOrWhitespace and verbose - - let summaryIndentLen = indent.len + 7 - let summaryWidth = width - summaryIndentLen - - let summaryLines = issue.summary - .wrapWords(summaryWidth) - .splitLines - - result &= summaryLines[0].withColor(fgWhite) - - for line in summaryLines[1..^1]: - result &= "\p" & line.indent(summaryIndentLen) - - var lastLineLen = summaryLines[^1].len - - if issue.hasProp("delegated-to"): - if lastLineLen + issue["delegated-to"].len + 1 < summaryWidth: - result &= " " & issue["delegated-to"].withColor(fgMagenta) - lastLineLen += issue["delegated-to"].len + 1 - else: - result &= "\p" & issue["delegated-to"] - .withColor(fgMagenta) - .indent(summaryIndentLen) - lastLineLen = issue["delegated-to"].len - - if issue.tags.len > 0: - let tagsStrLines = ("(" & issue.tags.join(", ") & ")") - .wrapWords(summaryWidth) - .splitLines - - if tagsStrLines.len == 1 and - (lastLineLen + tagsStrLines[0].len + 1) < summaryWidth: - result &= " " & tagsStrLines[0].withColor(fgGreen) - lastLineLen += tagsStrLines[0].len + 1 - else: - result &= "\p" & tagsStrLines - .mapIt(it.indent(summaryIndentLen)) - .join("\p") - .withColor(fgGreen) - lastLineLen = tagsStrLines[^1].len - - if issue.hasProp("pending"): - result &= "\p" & ("Pending: " & issue["pending"]) - .wrapwords(summaryWidth) - .withColor(fgCyan) - .indent(summaryIndentLen) - - if showDetails: - result &= "\p" & issue.details - .strip - .withColor(fgBlack, bright = true) - .indent(summaryIndentLen) - - result &= termReset - -proc formatSectionIssueList*( - issues: seq[Issue], - width: int = 80, - indent: string = "", - verbose: bool = false): string = - - result = "" - for i in issues: - var issueText = 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 &= formatSectionIssueList(ctxIssues, innerWidth - 2, indent & " ", verbose) - result &= "\n" - - else: result &= formatSectionIssueList(issues, innerWidth, indent, verbose) - -proc loadIssues(ctx: CliContext, state: IssueState) = - ctx.issues[state] = loadIssues(ctx.cfg.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 = ctx.cfg.tasksDir.loadAllIssues() - -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(";"): @@ -216,16 +31,6 @@ proc parseExclPropertiesOption(propsOpt: string): TableRef[string, seq[string]] if result.hasKey(pair[0]): result[pair[0]].add(val) else: result[pair[0]] = @[val] -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. @@ -248,96 +53,6 @@ proc edit(issue: Issue) = getCurrentExceptionMsg() issue.store() -proc list( - ctx: CliContext, - filter: Option[IssueFilter], - states: Option[seq[IssueState]], - showToday = false, - showFuture = false, - 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"))) - - if isatty(stdout): - stdout.write ctx.formatSection(ctx.issues[state], state, "", verbose) - - else: - stdout.writeLine ctx.issues[state] - .mapIt(formatPlainIssueSummary(it)) - .join("\n") - - - 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)) - - if isatty(stdout): - stdout.write ctx.formatSection(visibleIssues, s, indent, verbose) - - else: - stdout.writeLine visibleIssues - .mapIt(formatPlainIssueSummary(it)) - .join("\n") - - # Future items - if future: - if today: ctx.writeHeader("Future") - - let futureCategories = - if showToday: @[Todo] - else: @[Pending, Todo] - - for s in futureCategories: - 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)) - - if isatty(stdout): - stdout.write ctx.formatSection(visibleIssues, s, indent, verbose) - - else: - stdout.writeLine visibleIssues - .mapIt(formatPlainIssueSummary(it)) - .join("\n") - - trace "listing complete" - when isMainModule: try: @@ -698,6 +413,15 @@ when isMainModule: 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"]) + except CatchableError: fatal getCurrentExceptionMsg() debug getCurrentException().getStackTrace() diff --git a/src/pit/cliconstants.nim b/src/pit/cliconstants.nim index f91ce1b..7251c50 100644 --- a/src/pit/cliconstants.nim +++ b/src/pit/cliconstants.nim @@ -1,4 +1,4 @@ -const PIT_VERSION* = "4.27.0" +const PIT_VERSION* = "4.28.0" const USAGE* = """Usage: pit ( new | add) [] [options] @@ -16,6 +16,7 @@ const USAGE* = """Usage: pit add-binary-property [options] pit get-binary-property [options] pit show-dupes + pit sync [...] [options] pit help [options] Options: @@ -74,11 +75,13 @@ Options: -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. + + --dry-run Currently only supported by the `sync` command: + only print the changes that would be made, but do + not actually make them. """ const ONLINE_HELP* = """Issue States: @@ -186,4 +189,4 @@ Issue Properties: If present, expected to be a comma-delimited list of text tags. The -g option is a short-hand for '-p tags:'. -""" \ No newline at end of file +""" diff --git a/src/pit/formatting.nim b/src/pit/formatting.nim new file mode 100644 index 0000000..885475e --- /dev/null +++ b/src/pit/formatting.nim @@ -0,0 +1,249 @@ +import std/[options, sequtils, wordwrap, tables, terminal, times, unicode, wordwrap] +import cliutils, uuids +import std/strutils except alignLeft, capitalize, strip, toLower, toUpper +import ./libpit + +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*(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 formatPlainIssueSummary*(issue: Issue): string = + + result = "$#: $# $#" % [ + $issue.state, + ($issue.id)[0..<6], + issue.summary ] + + if issue.hasProp("delegated-to") or issue.hasProp("pending"): + var parts = newSeq[string]() + + if issue.hasProp("delegated-to"): + parts.add("delegated to " & issue["delegated-to"]) + + if issue.hasProp("pending"): + parts.add("pendin: " & issue["pending"]) + + result &= "($#)" % [ parts.join("; ") ] + + +proc formatSectionIssue*( + issue: Issue, + width: int = 80, + indent = "", + verbose = false): string = + + result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true) & " " + + let showDetails = not issue.details.isEmptyOrWhitespace and verbose + + let summaryIndentLen = indent.len + 7 + let summaryWidth = width - summaryIndentLen + + let summaryLines = issue.summary + .wrapWords(summaryWidth) + .splitLines + + result &= summaryLines[0].withColor(fgWhite) + + for line in summaryLines[1..^1]: + result &= "\p" & line.indent(summaryIndentLen) + + var lastLineLen = summaryLines[^1].len + + if issue.hasProp("delegated-to"): + if lastLineLen + issue["delegated-to"].len + 1 < summaryWidth: + result &= " " & issue["delegated-to"].withColor(fgMagenta) + lastLineLen += issue["delegated-to"].len + 1 + else: + result &= "\p" & issue["delegated-to"] + .withColor(fgMagenta) + .indent(summaryIndentLen) + lastLineLen = issue["delegated-to"].len + + if issue.tags.len > 0: + let tagsStrLines = ("(" & issue.tags.join(", ") & ")") + .wrapWords(summaryWidth) + .splitLines + + if tagsStrLines.len == 1 and + (lastLineLen + tagsStrLines[0].len + 1) < summaryWidth: + result &= " " & tagsStrLines[0].withColor(fgGreen) + lastLineLen += tagsStrLines[0].len + 1 + else: + result &= "\p" & tagsStrLines + .mapIt(it.indent(summaryIndentLen)) + .join("\p") + .withColor(fgGreen) + lastLineLen = tagsStrLines[^1].len + + if issue.hasProp("pending"): + result &= "\p" & ("Pending: " & issue["pending"]) + .wrapwords(summaryWidth) + .withColor(fgCyan) + .indent(summaryIndentLen) + + if showDetails: + result &= "\p" & issue.details + .strip + .withColor(fgBlack, bright = true) + .indent(summaryIndentLen) + + result &= termReset + + +proc formatSectionIssueList*( + issues: seq[Issue], + width: int = 80, + indent: string = "", + verbose: bool = false): string = + + result = "" + for i in issues: + var issueText = formatSectionIssue(i, width, indent, verbose) + result &= issueText & "\n" + + +proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState, + indent = "", verbose = false): string = + let innerWidth = terminalWidth() - (indent.len * 2) + + result = termColor(fgBlue) & + (indent & ".".repeat(innerWidth)) & "\n" & + state.displayName.center(terminalWidth()) & "\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 &= formatSectionIssueList(ctxIssues, innerWidth - 2, indent & " ", verbose) + result &= "\n" + + else: result &= formatSectionIssueList(issues, innerWidth, indent, verbose) + + +proc writeHeader*(ctx: CliContext, header: string) = + stdout.setForegroundColor(fgRed, true) + stdout.writeLine('_'.repeat(terminalWidth())) + stdout.writeLine(header.center(terminalWidth())) + stdout.writeLine('~'.repeat(terminalWidth())) + stdout.resetAttributes + + +proc list*( + ctx: CliContext, + filter: Option[IssueFilter], + states: Option[seq[IssueState]], + showToday = false, + showFuture = false, + 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"))) + + if isatty(stdout): + stdout.write ctx.formatSection(ctx.issues[state], state, "", verbose) + + else: + stdout.writeLine ctx.issues[state] + .mapIt(formatPlainIssueSummary(it)) + .join("\n") + + + 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)) + + if isatty(stdout): + stdout.write ctx.formatSection(visibleIssues, s, indent, verbose) + + else: + stdout.writeLine visibleIssues + .mapIt(formatPlainIssueSummary(it)) + .join("\n") + + # Future items + if future: + if today: ctx.writeHeader("Future") + + let futureCategories = + if showToday: @[Todo] + else: @[Pending, Todo] + + for s in futureCategories: + 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)) + + if isatty(stdout): + stdout.write ctx.formatSection(visibleIssues, s, indent, verbose) + + else: + stdout.writeLine visibleIssues + .mapIt(formatPlainIssueSummary(it)) + .join("\n") + + trace "listing complete" diff --git a/src/pit/libpit.nim b/src/pit/libpit.nim index 0d83b6a..ffb294d 100644 --- a/src/pit/libpit.nim +++ b/src/pit/libpit.nim @@ -33,13 +33,22 @@ type PitConfig* = ref object tasksDir*: string contexts*: TableRef[string, string] + syncTargets*: seq[JsonNode] cfg*: CombinedConfig + CliContext* = ref object + cfg*: PitConfig + contexts*: TableRef[string, string] + defaultContext*: Option[string] + issues*: TableRef[IssueState, seq[Issue]] + triggerPtk*, verbose*: bool + Recurrence* = object cloneId*: Option[string] interval*: TimeInterval isFromCompletion*: bool + const DONE_FOLDER_FORMAT* = "yyyy-MM" const ISO8601_MS = "yyyy-MM-dd'T'HH:mm:ss'.'fffzzz" @@ -68,6 +77,11 @@ proc displayName*(s: IssueState): string = of Todo: result = "Todo" of TodoToday: result = "Todo" + +proc sameDay*(a, b: DateTime): bool = + result = a.year == b.year and a.yearday == b.yearday + + ## Allow issue properties to be accessed as if the issue was a table proc `[]`*(issue: Issue, key: string): string = return issue.properties[key] @@ -75,6 +89,7 @@ proc `[]`*(issue: Issue, key: string): string = proc `[]=`*(issue: Issue, key: string, value: string) = issue.properties[key] = value + ## Issue property accessors proc hasProp*(issue: Issue, key: string): bool = return issue.properties.hasKey(key) @@ -113,6 +128,7 @@ proc getRecurrence*(issue: Issue): Option[Recurrence] = else: weeks(1), cloneId: c[6])) + ## Issue filtering proc initFilter*(): IssueFilter = result = IssueFilter( @@ -173,6 +189,7 @@ proc parseDate*(d: string): DateTime = continue raise newException(ValueError, "Unable to parse input as a date: " & d & errMsg) + ## Parse and format issues proc fromStorageFormat*(id: string, issueTxt: string): Issue = type ParseState = enum ReadingSummary, ReadingProps, ReadingDetails @@ -238,6 +255,7 @@ proc toStorageFormat*(issue: Issue, withComments = false): string = result = lines.join("\n") + ## Load and store from filesystem proc loadIssue*(filePath: string): Issue = result = fromStorageFormat(splitFile(filePath).name, readFile(filePath)) @@ -373,6 +391,7 @@ proc nextRecurrence*(tasksDir: string, rec: Recurrence, defaultIssue: Issue): Is result.setDateTime("hide-until", nextTime) + ## Utilities for working with issue collections. proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] = var f: seq[Issue] = issues @@ -416,6 +435,7 @@ proc find*( result = @[] for stateIssues in issues.values: result &= stateIssues.filter(filter) + ### Configuration utilities proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitConfig = var pitrcFilename: string @@ -440,7 +460,8 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo result = PitConfig( cfg: cfg, contexts: newTable[string,string](), - tasksDir: cfg.getVal("tasks-dir", "")) + tasksDir: cfg.getVal("tasks-dir", ""), + syncTargets: cfg.getJson("sync-targets", newJArray()).getElems) for k, v in cfg.getJson("contexts", newJObject()): result.contexts[k] = v.getStr() @@ -455,3 +476,38 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo for s in IssueState: if not dirExists(result.tasksDir / $s): (result.tasksDir / $s).createDir + + +## CliContext functionality +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]](), + triggerPtk: cliJson.getOrDefault("triggerPtk").getBool(false)) + +proc loadIssues*(ctx: CliContext, state: IssueState) = + ctx.issues[state] = loadIssues(ctx.cfg.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 = ctx.cfg.tasksDir.loadAllIssues() + +proc filterIssues*(ctx: CliContext, filter: IssueFilter) = + for state, issueList in ctx.issues: + ctx.issues[state] = issueList.filter(filter) diff --git a/src/pit/sync_pbm_vsb.nim b/src/pit/sync_pbm_vsb.nim new file mode 100644 index 0000000..17eb8f9 --- /dev/null +++ b/src/pit/sync_pbm_vsb.nim @@ -0,0 +1,176 @@ +import std/[httpclient, json, jsonutils, logging, options, sets, strutils, + terminal, times, tables] +import timeutils, uuids, zero_functional +import ./formatting, ./libpit + +type + PbmVsbSyncContext = object + pit: PitConfig + apiBaseUrl: string + apiToken: string + issueContext: string + http: HttpClient + + ServerTask* = object + id*: string + summary*: string + details*: string + state*: string + lastUpdatedAt*: DateTime + archivedAt*: Option[DateTime] + tags*: seq[string] + priority*: Option[string] + project*: Option[string] + milestone*: Option[string] + hideUntil*: Option[DateTime] + + +proc `%`*(dt: DateTime): JsonNode = %(dt.formatIso8601) +func toJsonHook(dt: DateTime): JsonNode = %(dt.formatIso8601) +proc fromJsonHook(dt: var DateTime, n: JsonNode): void = + dt = n.getStr.parseIso8601 + + +#func `%`*(p: Project): JsonNode = toJson(p) +func `%`*(t: ServerTask): JsonNode = toJson(t) + + +proc toServerTask(i: Issue): ServerTask = + return ServerTask( + id: $i.id, + summary: i.summary, + details: i.details, + state: $i.state, + tags: i.tags, + lastUpdatedAt: + if i.hasProp("last-updated"): parseIso8601(i["last-updated"]) + else: now(), + priority: + if i.hasProp("priority"): some(i["priority"]) + else: none[string](), + project: + if i.hasProp("project"): some(i["project"]) + else: none[string](), + milestone: + if i.hasProp("milestone"): some(i["milestone"]) + else: none[string](), + hideUntil: + if i.hasProp("hide-until"): some(parseIso8601(i["hide-until"])) + else: none[DateTime]()) + + +func getOrFail(n: JsonNode, k: string): JsonNode = + if not n.hasKey(k): + raise newException(ValueError, "missing key '" & k & "'") + return n[k] + + +proc initSyncContext*(pit: PitConfig, syncConfig: JsonNode): PbmVsbSyncContext = + result.pit = pit + result.apiBaseUrl = syncConfig.getOrFail("apiBaseUrl").getStr + result.apiToken = syncConfig.getOrFail("apiToken").getStr + result.issueContext = syncConfig.getOrFail("context").getStr + result.http = newHttpClient() + result.http.headers = newHttpHeaders({ "Authorization": "Bearer " & result.apiToken }) + + +proc fetchServerTasks*(ctx: PbmVsbSyncContext): seq[ServerTask] = + result = newSeq[ServerTask]() + let url = ctx.apiBaseUrl & "/tasks" + + debug "Fetching tasks from server:\n\t" & url + let resp = ctx.http.get(url) + if resp.status != "200": + debug("Received " & resp.status & ": " & resp.body) + raise newException(Exception, "Failed to fetch tasks from server") + fromJson(result, parseJson(resp.body)["data"]) + + +proc updateTask*(ctx: PbmVsbSyncContext, task: ServerTask): void = + let url = ctx.apiBaseUrl & "/task" & task.id + let body = %task + let resp = ctx.http.post(url, $body) + if resp.status != "200": + debug("Received " & resp.status & ": " & resp.body) + raise newException(Exception, "Failed to update task " & task.id & "on server") + + +proc updateTasks*(ctx: PbmVsbSyncContext, tasks: seq[ServerTask]): void = + let url = ctx.apiBaseUrl & "/tasks" + let body = %tasks + let resp = ctx.http.put(url, $body) + if resp.status != "200": + debug("Received " & resp.status & ": " & resp.body) + raise newException(Exception, "Failed to update tasks on server") + + +proc deleteTask*(ctx: PbmVsbSyncContext, taskId: string): void = + let url = ctx.apiBaseUrl & "/tasks/" & taskId + let resp = ctx.http.delete(url) + if resp.status != "200": + debug("Received " & resp.status & ": " & resp.body) + raise newException(Exception, "Failed to delete task " & taskId & " on server") + + +proc sync*( + ctx: PbmVsbSyncContext, + dryRun = true, + batchSize = 100): void = + + if not dryRun: echo "NOT DRY RUN" + # We're going to do a uni-directional sync, pushing local issues to the + # server. However, we only want to update issues that have changed since + # the last sync based on the *last-updated* property. + + # Note that all the logic assumes the server only has one context of tasks. + # If this ever changes, this logic will need to change. + let filter = propsFilter(newTable({ "context": ctx.issueContext })) + let allIssues = ctx.pit.tasksDir.loadAllIssues() + + var issues = newSeq[Issue]() + for state in allIssues.keys: + issues.add(allIssues[state].filter(filter)) + + let serverTasks = fetchServerTasks(ctx) + debug "Loaded $# server tasks" % [$serverTasks.len] + var issuesToPush = newSeq[ServerTask]() + + var unmatchedServerTaskIds = toHashSet(serverTasks --> map(it.id)) + + # Process all local issues + info "Updating the following tasks for context " & ctx.issueContext + for lentIssue in issues: + let i = lentIssue + let foundTask = serverTasks --> find(it.id == $i.id) + + if foundTask.isSome: + # There is a server task for this issue + unmatchedServerTaskIds.excl(foundTask.get.id) + + if i.hasProp("last-updated"): + var localUpdate = parseIso8601(i["last-updated"]) + localUpdate.nanosecond = 0 + if foundTask.get.lastUpdatedAt >= localUpdate: + # but we don't have any update + continue + + # We fell through the conditional block above, so either there isn't a + # server task, or we *do* have an update, + issuesToPush.add(toServerTask(i)) + info " " & formatSectionIssue(i, width = terminalWidth() - 8) + + # Now archive the issues on the server that we didn't see locally. + info "Archiving the following tasks for context " & ctx.issueContext + for task in serverTasks: + var toArchive = task + if unmatchedServerTaskIds.contains(task.id) and + toArchive.archivedAt.isNone: + toArchive.archivedAt = some(now()) + issuesToPush.add(toArchive) + + if not dryRun: + var offset = 0 + while (offset < issuesToPush.len): + let batchSize = min(issuesToPush.len - offset, batchSize) + updateTasks(ctx, issuesToPush[offset ..< offset + batchSize]) + offset += batchSize