Compare commits

...

3 Commits

Author SHA1 Message Date
b0e3f5a9d8 Expose issue formating functionality. 2023-03-21 11:11:44 -05:00
fee4ba70a6 Update state field when changing an issue's state. 2023-03-21 10:27:25 -05:00
171adbb59d Make IssueState available as a field on Issue.
* Add `state` on `Issue` to be able to query the state of an issue even
  if you only have a reference to this issue and don't have a reference
  to the context or issues table. This does not change the persisted
  format of the issue. On disk the state of an issue is still
  represented by it's location in the file hierarchy.

* Refactored libpit to use zero_functional instead of sequtils.
2023-03-21 08:30:29 -05:00
4 changed files with 61 additions and 40 deletions

View File

@ -1,6 +1,6 @@
# Package
version = "4.22.2"
version = "4.23.2"
author = "Jonathan Bernard"
description = "Personal issue tracker."
license = "MIT"
@ -14,7 +14,8 @@ requires @[
"nim >= 1.4.0",
"docopt >= 0.6.8",
"jester >= 0.5.0",
"uuids >= 0.1.10"
"uuids >= 0.1.10",
"zero_functional"
]
# Dependencies from git.jdb-software.com/nim-jdb/packages

View File

@ -1,8 +1,8 @@
## Personal Issue Tracker CLI interface
## ====================================
import std/algorithm, std/logging, std/options, std/os, std/sequtils,
std/wordwrap, std/tables, std/terminal, std/times, std/unicode
import std/[algorithm, logging, options, os, sequtils, wordwrap, tables,
terminal, times, unicode]
import cliutils, data_uri, docopt, json, timeutils, uuids
from nre import re
@ -51,7 +51,7 @@ proc getIssueContextDisplayName(ctx: CliContext, context: string): string =
else: return context.capitalize()
return ctx.contexts[context]
proc formatIssue(ctx: CliContext, issue: Issue): string =
proc formatIssue*(issue: Issue): string =
result = ($issue.id).withColor(fgBlack, true) & "\n"&
issue.summary.withColor(fgWhite) & "\n"
@ -70,10 +70,9 @@ proc formatIssue(ctx: CliContext, issue: Issue): string =
result &= termReset
proc formatSectionIssue(
ctx: CliContext,
proc formatSectionIssue*(
issue: Issue,
width: int,
width: int = 80,
indent = "",
verbose = false): string =
@ -135,12 +134,15 @@ proc formatSectionIssue(
result &= termReset
proc formatSectionIssueList(ctx: CliContext, issues: seq[Issue], width: int,
indent: string, verbose: bool): string =
proc formatSectionIssueList*(
issues: seq[Issue],
width: int = 80,
indent: string = "",
verbose: bool = false): string =
result = ""
for i in issues:
var issueText = ctx.formatSectionIssue(i, width, indent, verbose)
var issueText = formatSectionIssue(i, width, indent, verbose)
result &= issueText & "\n"
proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
@ -161,10 +163,10 @@ proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
indent & ctx.getIssueContextDisplayName(context) & ":" &
termReset & "\n\n"
result &= ctx.formatSectionIssueList(ctxIssues, innerWidth - 2, indent & " ", verbose)
result &= formatSectionIssueList(ctxIssues, innerWidth - 2, indent & " ", verbose)
result &= "\n"
else: result &= ctx.formatSectionIssueList(issues, innerWidth, indent, verbose)
else: result &= formatSectionIssueList(issues, innerWidth, indent, verbose)
proc loadIssues(ctx: CliContext, state: IssueState) =
ctx.issues[state] = loadIssues(ctx.cfg.tasksDir, state)
@ -379,7 +381,7 @@ when isMainModule:
ctx.cfg.tasksDir.store(issue, state)
stdout.writeLine ctx.formatIssue(issue)
stdout.writeLine formatIssue(issue)
elif args["reorder"]:
ctx.reorder(parseEnum[IssueState]($args["<state>"]))
@ -444,7 +446,7 @@ when isMainModule:
let nextIssue = ctx.cfg.tasksDir.nextRecurrence(issue.getRecurrence.get, issue)
ctx.cfg.tasksDir.store(nextIssue, Todo)
info "created the next recurrence:"
stdout.writeLine ctx.formatIssue(nextIssue)
stdout.writeLine formatIssue(nextIssue)
issue.changeState(ctx.cfg.tasksDir, targetState)
@ -567,7 +569,7 @@ when isMainModule:
elif issueIdsOption.isSome:
for issueId in issueIdsOption.get:
let issue = ctx.cfg.tasksDir.loadIssueById(issueId)
stdout.writeLine ctx.formatIssue(issue)
stdout.writeLine formatIssue(issue)
# List all issues
else:

View File

@ -1,4 +1,4 @@
const PIT_VERSION* = "4.22.2"
const PIT_VERSION* = "4.23.2"
const USAGE* = """Usage:
pit ( new | add) <summary> [<state>] [options]

View File

@ -1,8 +1,9 @@
import std/json, std/logging, std/options, std/os, std/sequtils, std/strformat,
std/strutils, std/tables, std/times
import cliutils, docopt, langutils, timeutils, uuids
import std/[json, logging, options, os, strformat, strutils, tables, times]
import cliutils, docopt, langutils, uuids, zero_functional
import nre except toSeq
import timeutils except `>`
from sequtils import deduplicate, toSeq
type
Issue* = ref object
@ -11,6 +12,7 @@ type
summary*, details*: string
properties*: TableRef[string, string]
tags*: seq[string]
state*: IssueState
IssueState* = enum
Current = "current",
@ -39,6 +41,7 @@ type
isFromCompletion*: bool
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]+))?"
@ -201,12 +204,13 @@ proc fromStorageFormat*(id: string, issueTxt: string): Issue =
continue
let parts = line.split({':'}, 1).mapIt(it.strip())
let parts = line.split({':'}, 1) --> map(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())
if parts[0] == "tags":
result.tags = parts[1].split({','}) --> map(it.strip())
else: result[parts[0]] = parts[1]
of ReadingDetails:
@ -216,12 +220,17 @@ proc fromStorageFormat*(id: string, issueTxt: string): Issue =
proc toStorageFormat*(issue: Issue, withComments = false): string =
var lines: seq[string] = @[]
if withComments: lines.add("# Summary (one line):")
lines.add(issue.summary)
if withComments: lines.add("# Properties (\"key:value\" per line):")
issue.properties["last-updated"] = now().format(ISO8601_MS)
for key, val in issue.properties:
if not val.isEmptyOrWhitespace: lines.add(key & ": " & val)
if issue.tags.len > 0: lines.add("tags: " & issue.tags.join(","))
if not isEmptyOrWhitespace(issue.details) or withComments:
if withComments: lines.add("# Details go below the \"--------\"")
lines.add("--------")
@ -234,6 +243,11 @@ proc loadIssue*(filePath: string): Issue =
result = fromStorageFormat(splitFile(filePath).name, readFile(filePath))
result.filepath = filePath
let parentDirName = filePath.splitFile().dir.splitFile().name
let issueState = IssueState.items.toSeq --> find($it == parentDirName)
if issueState.isSome: result.state = issueState.get
else: result.state = IssueState.Done
proc loadIssueById*(tasksDir, id: string): Issue =
for path in walkDirRec(tasksDir):
if path.splitFile.name.startsWith(id):
@ -273,10 +287,10 @@ proc loadIssues*(path: string): seq[Issue] =
let orderedIds =
if fileExists(orderFile):
toSeq(orderFile.lines)
.mapIt(it.split(' ')[0])
.deduplicate
.filterIt(not it.startsWith("> ") and not it.isEmptyOrWhitespace)
(orderFile.lines.toSeq -->
map(it.split(' ')[0]).
filter(not it.startsWith("> ") and not it.isEmptyOrWhitespace)).
deduplicate()
else: newSeq[string]()
type TaggedIssue = tuple[issue: Issue, ordered: bool]
@ -318,6 +332,7 @@ proc changeState*(issue: Issue, tasksDir: string, newState: IssueState) =
if newState == Done: issue.setDateTime("completed", getTime().local)
tasksDir.store(issue, newState)
if oldFilePath != issue.filepath: removeFile(oldFilepath)
issue.state = newState
proc delete*(issue: Issue) = removeFile(issue.filepath)
@ -332,6 +347,7 @@ proc nextRecurrence*(tasksDir: string, rec: Recurrence, defaultIssue: Issue): Is
result = Issue(
id: genUUID(),
state: baseIssue.state,
summary: baseIssue.summary,
properties: newProps,
tags: baseIssue.tags)
@ -354,36 +370,38 @@ proc nextRecurrence*(tasksDir: string, rec: Recurrence, defaultIssue: Issue): Is
## Utilities for working with issue collections.
proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
result = issues
var f: seq[Issue] = issues
for k,v in filter.properties:
result = result.filterIt(it.hasProp(k) and it[k] == v)
f = f --> filter(it.hasProp(k) and it[k] == v)
for k,v in filter.exclProperties:
result = result.filter(proc (iss: Issue): bool =
not iss.hasProp(k) or
not v.anyIt(it == iss[k])
)
f = f --> filter(not (it.hasProp(k) and v.contains(it[k])))
if filter.completedRange.isSome:
let range = filter.completedRange.get
result = result.filterIt(
f = f --> filter(
not it.hasProp("completed") or
it.getDateTime("completed").between(range.b, range.e))
if filter.summaryMatch.isSome:
let p = filter.summaryMatch.get
result = result.filterIt(it.summary.find(p).isSome)
f = f --> filter(it.summary.find(p).isSome)
if filter.fullMatch.isSome:
let p = filter.fullMatch.get
result = result.filterIt( it.summary.find(p).isSome or it.details.find(p).isSome)
f = f -->
filter(it.summary.find(p).isSome or it.details.find(p).isSome)
for tag in filter.hasTags:
result = result.filterIt(it.tags.find(tag) >= 0)
for tagLent in filter.hasTags:
let tag = tagLent
f = f --> filter(it.tags.find(tag) >= 0)
for exclTag in filter.exclTags:
result = result.filterIt(it.tags.find(exclTag) < 0)
for exclTagLent in filter.exclTags:
let exclTag = exclTagLent
f = f --> filter(it.tags.find(exclTag) < 0)
return f # not using result because zero_functional doesn't play nice with it
proc find*(
issues: TableRef[IssueState, seq[Issue]],
@ -400,7 +418,7 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
".pitrc", $getEnv("PITRC"), $getEnv("HOME") & "/.pitrc"]
var pitrcFilename: string =
foldl(pitrcLocations, if len(a) > 0: a elif fileExists(b): b else: "")
pitrcLocations --> fold("", if fileExists(it): it else: a)
if not fileExists(pitrcFilename):
warn "could not find .pitrc file: " & pitrcFilename