diff --git a/pit.nimble b/pit.nimble index cef5052..07927c5 100644 --- a/pit.nimble +++ b/pit.nimble @@ -1,6 +1,6 @@ # Package -version = "4.33.2" +version = "4.33.3" author = "Jonathan Bernard" description = "Personal issue tracker." license = "MIT" @@ -10,8 +10,9 @@ bin = @["pit"] # Dependencies -requires "nim >= 1.4.0" +requires "nim >= 1.6.0" requires "docopt >= 0.7.1" +requires "regex >= 0.26.3" requires "uuids >= 0.1.10" requires "zero_functional" @@ -19,7 +20,7 @@ requires "zero_functional" requires "cliutils >= 0.11.1" requires "langutils >= 0.4.0" requires "timeutils >= 0.5.4" -requires "data_uri > 1.0.0" +requires "filetype >= 0.9.0" requires "update_nim_package_version >= 0.2.0" task updateVersion, "Update the version of this package.": diff --git a/src/pit.nim b/src/pit.nim index f1cde08..4397e9c 100644 --- a/src/pit.nim +++ b/src/pit.nim @@ -1,13 +1,14 @@ ## 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 +import std/[algorithm, logging, options, os, sequtils, sets, tables, times, + unicode] +import docopt, json, timeutils, uuids, zero_functional -from nre import match, re +from regex import match, re2 import strutils except alignLeft, capitalize, strip, toUpper, toLower -import pit/[cliconstants, formatting, libpit, projects, sync_pbm_vsb] +import pit/[ansiterm, cliconstants, data_uri, formatting, libpit, projects, + sync_pbm_vsb] export formatting, libpit @@ -73,7 +74,7 @@ proc addIssue( if issueProps.hasKey("context"): ctx.filterIssues(propsFilter(newTable({"context": issueProps["context"]}))) - let numberRegex = re("^[0-9]+$") + let numberRegex = re2("^[0-9]+$") for propName in defaultProps: if not issueProps.hasKey(propName): let allIssues: seq[seq[Issue]] = toSeq(values(ctx.issues)) @@ -98,7 +99,7 @@ proc addIssue( let resp = stdin.readLine.strip let numberResp = resp.match(numberRegex) - if numberResp.isSome: + if numberResp: let idx = parseInt(resp) if idx >= 0 and idx < previousValues.len: issueProps[propName] = previousValues[idx] @@ -211,10 +212,10 @@ when isMainModule: # If they supplied text matches, add that to the filter. if args["--match"]: - filter.summaryMatch = some(re("(?i)" & $args["--match"])) + filter.summaryMatch = some(re2("(?i)" & $args["--match"])) if args["--match-all"]: - filter.fullMatch = some(re("(?i)" & $args["--match-all"])) + filter.fullMatch = some(re2("(?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"): diff --git a/src/pit/ansiterm.nim b/src/pit/ansiterm.nim new file mode 100644 index 0000000..efdb72f --- /dev/null +++ b/src/pit/ansiterm.nim @@ -0,0 +1,75 @@ +import std/[sequtils, strutils] + +const CSI = "\x1b[" +const RESET_FORMATTING* = "\x1b[0m" + +type + TrueColor* = tuple[r, g, b: int] + + TerminalColors* = enum + cBlack = 0, cRed, cGreen, cYellow, cBlue, cMagenta, cCyan, cWhite, + cDefault = 9 + + cBrightBlack = 60, cBrightRed, cBrightGreen, cBrightYellow, cBrightBlue, + cBrightMagenta, cBrightCyan, cBrightWhite + + TerminalStyle* = enum + tsBold = 1, tsDim, tsItalic, tsUnderline, tsBlink, tsInverse, tsHidden, + tsStrikethrough + +func isAnsiTerminator(c: char): bool = + c in {'A'..'Z'} or c in {'a'..'z'} + +proc stripAnsi*(text: string): string = + var i = 0 + while i < text.len: + if i + 1 < text.len and text[i] == '\x1b' and text[i + 1] == '[': + i += 2 + while i < text.len and not text[i].isAnsiTerminator: + inc i + if i < text.len: + inc i + else: + result.add(text[i]) + inc i + +func ansiEscSeq*(style: set[TerminalStyle]): string = + CSI & toSeq(style).mapIt($int(it)).join(";") & "m" + +func ansiEscSeq*(fg: int): string = CSI & "38;5;" & $fg & "m" +func ansiEscSeq*(bg: int): string = CSI & "48;5;" & $bg & "m" + +func ansiEscSeq*(fg: TrueColor): string = + "$#38;2;$#;$#;$#m" % [CSI, $fg.r, $fg.g, $fg.b] + +func ansiEscSeq*(bg: TrueColor): string = + "$#48;2;$#;$#;$#m" % [CSI, $bg.r, $bg.g, $bg.b] + +func ansiEscSeq*(fg: TerminalColors): string = CSI & $(int(fg) + 30) & "m" +func ansiEscSeq*(bg: TerminalColors): string = CSI & $(int(bg) + 40) & "m" + +func ansiEscSeq*( + fg: TerminalColors, + bg: TerminalColors, + style: set[TerminalStyle]): string = + result = CSI + if style != {}: + result &= toSeq(style).mapIt($int(it)).join(";") & ";" + result &= $(int(fg) + 30) & ";" & $(int(bg) + 40) & "m" + +func termFmt*( + text: string, + fg = cDefault, + bg = cDefault, + style: set[TerminalStyle] = {}): string = + ansiEscSeq(fg, bg, style) & text & RESET_FORMATTING + +func termFmt*[C: int | TrueColor]( + text: string, + fg, bg: C, + style: set[TerminalStyle] = {}): string = + ansiEscSeq(style) & ansiEscSeq(fg = fg) & ansiEscSeq(bg = bg) & + text & RESET_FORMATTING + +func color*(text: string, fg = cDefault, bg = cDefault): string = + termFmt(text, fg, bg, {}) diff --git a/src/pit/cliconstants.nim b/src/pit/cliconstants.nim index f90a169..e45f928 100644 --- a/src/pit/cliconstants.nim +++ b/src/pit/cliconstants.nim @@ -1,4 +1,4 @@ -const PIT_VERSION* = "4.33.2" +const PIT_VERSION* = "4.33.3" const USAGE* = """Usage: pit ( new | add) [] [options] @@ -71,10 +71,10 @@ Options: properties set. -m, --match Limit to issues whose summaries match the given - pattern (PCRE regex supported). + pattern (regex syntax supported). -M, --match-all Limit to the issues whose summaries or details - match the given pattern (PCRE regex supported). + match the given pattern (regex syntax supported). -v, --verbose Show issue details when listing issues. diff --git a/src/pit/data_uri.nim b/src/pit/data_uri.nim new file mode 100644 index 0000000..66fbc1d --- /dev/null +++ b/src/pit/data_uri.nim @@ -0,0 +1,20 @@ +import std/[base64, strutils] + +import filetype + +proc encodeAsDataUri*(value: string, mimeType: string = ""): string = + let actualMimeType = + if mimeType.isEmptyOrWhitespace: filetype.match(cast[seq[byte]](value)).mime.value + else: mimeType + + "data:" & actualMimeType & ";base64," & encode(value) + +proc decodeDataUri*(dataUri: string): string = + if not dataUri.startsWith("data:"): + raise newException(Exception, "input does not look like a valid data URI") + + let commaIdx = dataUri.find(',') + if commaIdx < 0: + raise newException(Exception, "input does not look like a valid data URI") + + decode(dataUri[commaIdx + 1..^1]) diff --git a/src/pit/formatting.nim b/src/pit/formatting.nim index b9fc255..949aee7 100644 --- a/src/pit/formatting.nim +++ b/src/pit/formatting.nim @@ -1,6 +1,7 @@ import std/[options, sequtils, tables, terminal, times, unicode, wordwrap] -import cliutils, uuids +import uuids import std/strutils except alignLeft, capitalize, strip, toLower, toUpper +import ./ansiterm import ./libpit proc adjustedTerminalWidth(): int = min(terminalWidth(), 80) diff --git a/src/pit/libpit.nim b/src/pit/libpit.nim index 554368d..6c4adec 100644 --- a/src/pit/libpit.nim +++ b/src/pit/libpit.nim @@ -1,8 +1,8 @@ import std/[json, logging, options, os, strformat, strutils, tables, times, unicode] -import cliutils, docopt, langutils, uuids, zero_functional +import cliutils/config, docopt, langutils, uuids, zero_functional -import nre except toSeq +import regex import timeutils except `>` from sequtils import deduplicate, toSeq @@ -25,7 +25,7 @@ type IssueFilter* = ref object completedRange*: Option[tuple[b, e: DateTime]] - fullMatch*, summaryMatch*: Option[Regex] + fullMatch*, summaryMatch*: Option[Regex2] inclStates*: seq[IssueState] exclStates*: seq[IssueState] hasTags*: seq[string] @@ -61,8 +61,8 @@ const MATCH_ANY* = "" const DONE_FOLDER_FORMAT* = "yyyy-MM" const ISO8601_MS = "yyyy-MM-dd'T'HH:mm:ss'.'fffzzz" -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 ISSUE_FILE_PATTERN = re2"[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 = re2"(every|after) ((\d+) )?((hour|day|week|month|year)s?)(, ([0-9a-fA-F]+))?" let traceStartTime = cpuTime() var lastTraceTime = traceStartTime @@ -116,26 +116,31 @@ proc setDateTime*(issue: Issue, key: string, dt: DateTime) = proc getRecurrence*(issue: Issue): Option[Recurrence] = if not issue.hasProp("recurrence"): return none[Recurrence]() - let m = issue["recurrence"].match(RECURRENCE_PATTERN) + let recurrence = issue["recurrence"] + var m = RegexMatch2() - if not m.isSome: + if not recurrence.match(RECURRENCE_PATTERN, m): 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 + proc captured(i: int): Option[string] = + let bounds = m.group(i) + if bounds == reNonCapture: none(string) + else: some(recurrence[bounds]) + + let timeVal = if captured(2).isSome: captured(2).get.parseInt else: 1 return some(Recurrence( - isFromCompletion: c[0].get == "after", + isFromCompletion: captured(0).get == "after", interval: - case c[4].get: + case captured(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])) + cloneId: captured(6))) proc setPriority*(issue: Issue, priority: IssuePriority) = issue["priority"] = $priority @@ -149,8 +154,8 @@ proc getPriority*(issue: Issue): IssuePriority = proc initFilter*(): IssueFilter = result = IssueFilter( completedRange: none(tuple[b, e: DateTime]), - fullMatch: none(Regex), - summaryMatch: none(Regex), + fullMatch: none(Regex2), + summaryMatch: none(Regex2), inclStates: @[], exclStates: @[], exclHidden: true, @@ -173,11 +178,11 @@ proc dateFilter*(range: tuple[b, e: DateTime]): IssueFilter = proc summaryMatchFilter*(pattern: string): IssueFilter = result = initFilter() - result.summaryMatch = some(re("(?i)" & pattern)) + result.summaryMatch = some(re2("(?i)" & pattern)) proc fullMatchFilter*(pattern: string): IssueFilter = result = initFilter() - result.fullMatch = some(re("(?i)" & pattern)) + result.fullMatch = some(re2("(?i)" & pattern)) proc hasTagsFilter*(tags: seq[string]): IssueFilter = result = initFilter() @@ -344,7 +349,7 @@ proc loadIssues*(path: string): seq[Issue] = var unorderedIssues: seq[TaggedIssue] = @[] for path in walkDirRec(path): - if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome(): + if extractFilename(path).match(ISSUE_FILE_PATTERN): unorderedIssues.add((loadIssue(path), false)) trace "loaded " & $unorderedIssues.len & " issues", true @@ -451,12 +456,12 @@ proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] = if filter.summaryMatch.isSome: let p = filter.summaryMatch.get - f = f --> filter(it.summary.find(p).isSome) + f = f --> filter(it.summary.contains(p)) if filter.fullMatch.isSome: let p = filter.fullMatch.get f = f --> - filter(it.summary.find(p).isSome or it.details.find(p).isSome) + filter(it.summary.contains(p) or it.details.contains(p)) for tagLent in filter.hasTags: let tag = tagLent diff --git a/src/pit/projects.nim b/src/pit/projects.nim index b882d0e..8b441a6 100644 --- a/src/pit/projects.nim +++ b/src/pit/projects.nim @@ -1,8 +1,8 @@ import std/[algorithm, json, jsonutils, options, os, sets, strutils, tables, terminal, times, unicode] from std/sequtils import repeat, toSeq -import cliutils, uuids, zero_functional -import ./[formatting, libpit] +import uuids, zero_functional +import ./[ansiterm, formatting, libpit] const NO_PROJECT* = "∅ No Project" const NO_MILESTONE* = "∅ No Milestone" diff --git a/src/pit_api.nim b/src/pit_api.nim index 99fad68..bee7000 100644 --- a/src/pit_api.nim +++ b/src/pit_api.nim @@ -7,9 +7,10 @@ # libpit directly rather than calling the pit cli executable. Unfortunately # this would require a non-trivial rewrite. -import asyncdispatch, cliutils, docopt, jester, json, logging, options, sequtils, strutils -import nre except toSeq +import asyncdispatch, docopt, jester, json, logging, options, sequtils, strutils +import cliutils/procutil +import pit/ansiterm import pit/libpit import pit/cliconstants @@ -23,6 +24,16 @@ const TXT = "text/plain" proc raiseEx(reason: string): void = raise newException(Exception, reason) +proc queryParamsToCliArgs[T](queryParams: T): seq[string] = + result = @[] + for k, v in queryParams: + if k.startsWith("arg"): + result.add(v) + else: + result.add("--" & k) + if v != "true": + result.add(v) + template halt(code: HttpCode, headers: RawHeaders, content: string): void = diff --git a/tests/test1.nim b/tests/test1.nim index ba13c99..1182ba8 100644 --- a/tests/test1.nim +++ b/tests/test1.nim @@ -1 +1,29 @@ -doAssert(1 + 1 == 2) +import std/[options, tables, times, unittest] + +import pit/libpit + +suite "regex-backed issue behavior": + test "summary and full-text filters are case-insensitive": + let issue = Issue( + summary: "Fix the Widget", + details: "Failure happens in the renderer", + properties: newTable[string, string](), + tags: @[]) + + check @[issue].filter(summaryMatchFilter("widget")).len == 1 + check @[issue].filter(summaryMatchFilter("missing")).len == 0 + check @[issue].filter(fullMatchFilter("RENDERER")).len == 1 + + test "recurrence values parse captures": + let issue = Issue( + summary: "repeating", + properties: newTable[string, string](), + tags: @[]) + + issue["recurrence"] = "after 2 weeks, abc123" + + let recurrence = issue.getRecurrence() + check recurrence.isSome + check recurrence.get.isFromCompletion + check recurrence.get.interval == weeks(2) + check recurrence.get.cloneId == some("abc123")