Compare commits

...

10 Commits

Author SHA1 Message Date
ed3058a9c8 Initial ideas around a related-to special property. 2023-12-15 21:34:53 -06:00
ddad90ddef Add examples to the online help for all special properties. 2023-12-15 21:34:38 -06:00
34ce2b61b9 When creating new recurrences, put them in the TodoToday state, not Todo. 2023-07-06 08:07:22 -05:00
661d5959c6 Add show-dupes command, fix BareExcept warnings. 2023-05-19 09:24:53 -05:00
6665f09b7b Fixed missed version bump in cliconstants. 2023-05-19 09:05:02 -05:00
bcb1c7c17c Extract logic for locating the config file to the cliutils library. 2023-05-13 07:30:25 -05:00
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
d01d6e37f4 Update timeutils version to include support for the shorter ISO8601 date format. 2023-02-28 23:29:06 -06:00
5 changed files with 348 additions and 77 deletions

174
nimble.lock Normal file
View File

@ -0,0 +1,174 @@
{
"version": 2,
"packages": {
"asynctools": {
"version": "0.1.1",
"vcsRevision": "0e6bdc3ed5bae8c7cc9e03cfbf66b7c882a908a7",
"url": "https://github.com/cheatfate/asynctools",
"downloadMethod": "git",
"dependencies": [],
"checksums": {
"sha1": "54314dceabb06b20908ecb0f2c007e9ff3aaa054"
}
},
"isaac": {
"version": "0.1.3",
"vcsRevision": "45a5cbbd54ff59ba3ed94242620c818b9aad1b5b",
"url": "https://github.com/pragmagic/isaac/",
"downloadMethod": "git",
"dependencies": [],
"checksums": {
"sha1": "05c3583a954715d84b0bf1be97f9a503249e9cdf"
}
},
"uuids": {
"version": "0.1.11",
"vcsRevision": "8cb8720b567c6bcb261bd1c0f7491bdb5209ad06",
"url": "https://github.com/pragmagic/uuids/",
"downloadMethod": "git",
"dependencies": [
"isaac"
],
"checksums": {
"sha1": "393f5fcefbc8ad3cf167e59760144208ff8f9f76"
}
},
"unicodedb": {
"version": "0.11.2",
"vcsRevision": "c70f8bc8c7373265670e0575bc5eda36fe3761b0",
"url": "https://github.com/nitely/nim-unicodedb",
"downloadMethod": "git",
"dependencies": [],
"checksums": {
"sha1": "612c5955f91bd90a263ce914d1d5de74a33af3c6"
}
},
"regex": {
"version": "0.20.1",
"vcsRevision": "66f144f935cc73977c61185fab15a3147bf117ff",
"url": "https://github.com/nitely/nim-regex",
"downloadMethod": "git",
"dependencies": [
"unicodedb"
],
"checksums": {
"sha1": "ea9b6600443e73b1ea89a477c7a5d1fce742c9da"
}
},
"docopt": {
"version": "0.7.0",
"vcsRevision": "17803d1205f9e752cce03a66b0a29b710520398e",
"url": "https://github.com/docopt/docopt.nim",
"downloadMethod": "git",
"dependencies": [
"regex"
],
"checksums": {
"sha1": "21150284640b882fa91147181c52da3e5bb44df8"
}
},
"filetype": {
"version": "0.9.0",
"vcsRevision": "1fe1e7d988cd802abc26505efb5a91891bd6f53e",
"url": "https://github.com/jiro4989/filetype",
"downloadMethod": "git",
"dependencies": [],
"checksums": {
"sha1": "d2242b94eeb0f6d3810a8c71af4664f28853e1be"
}
},
"zero_functional": {
"version": "1.3.0",
"vcsRevision": "edf3b7f59119f75706da435c2b7f080a0cf4960c",
"url": "https://github.com/zero-functional/zero-functional",
"downloadMethod": "git",
"dependencies": [],
"checksums": {
"sha1": "2dc01ca0925ac1c2dcb46a0c6d9c93c57a9cddec"
}
},
"update_nim_package_version": {
"version": "0.2.0",
"vcsRevision": "5a78579fd7f88014263aed38c60327c85f6f8bcf",
"url": "https://git.jdb-software.com/jdb/update-nim-package-version",
"downloadMethod": "git",
"dependencies": [],
"checksums": {
"sha1": "37052b7ce30d5493ef24253a82a6087350b4eabb"
}
},
"data_uri": {
"version": "1.0.2",
"vcsRevision": "f43ac66e44c37edd3cc7282d75d6fa2fa031b2ec",
"url": "",
"downloadMethod": "git",
"dependencies": [
"docopt",
"filetype",
"zero_functional",
"update_nim_package_version"
],
"checksums": {
"sha1": "949c11ffab4e85ff538b0bd3e5bb193f118b56d7"
}
},
"timeutils": {
"version": "0.5.4",
"vcsRevision": "a9308cbaf3c89496b5832ddd18404dc0debe66a2",
"url": "https://git.jdb-software.com/jdb/nim-time-utils.git",
"downloadMethod": "git",
"dependencies": [],
"checksums": {
"sha1": "9ecd1020f5644bc59acb2f44ca9d30f4c7f066d3"
}
},
"cliutils": {
"version": "0.8.0",
"vcsRevision": "b1cc4fbe51d5e617789363efe716793ebe5bc5f1",
"url": "https://git.jdb-software.com/jdb/nim-cli-utils",
"downloadMethod": "git",
"dependencies": [
"docopt"
],
"checksums": {
"sha1": "5b114094c314007fa6f15e62852d62a58a3cbb62"
}
},
"langutils": {
"version": "0.4.0",
"vcsRevision": "8122660da3fc78132b823e76c9e990fd92802b0e",
"url": "https://git.jdb-software.com/jdb/nim-lang-utils.git",
"downloadMethod": "git",
"dependencies": [],
"checksums": {
"sha1": "2da09deb0e0bfc186000f0d941d06dd974bf6e58"
}
},
"httpbeast": {
"version": "0.4.1",
"vcsRevision": "abc13d11c210b614960fe8760e581d44cfb2e3e9",
"url": "https://github.com/dom96/httpbeast",
"downloadMethod": "git",
"dependencies": [
"asynctools"
],
"checksums": {
"sha1": "b23e57a401057dcb9b7fae1fb8279a6a2ce1d0b8"
}
},
"jester": {
"version": "0.5.0",
"vcsRevision": "a21b36a02b7745d6cdcda32d4ab3fba328cda17a",
"url": "https://github.com/dom96/jester/",
"downloadMethod": "git",
"dependencies": [
"httpbeast",
"asynctools"
],
"checksums": {
"sha1": "a192ca25bfc05d5de5c9a5fafca3b0cee47d82d2"
}
}
},
"tasks": {}
}

View File

@ -1,6 +1,6 @@
# Package # Package
version = "4.22.1" version = "4.24.0"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Personal issue tracker." description = "Personal issue tracker."
license = "MIT" license = "MIT"
@ -14,14 +14,15 @@ requires @[
"nim >= 1.4.0", "nim >= 1.4.0",
"docopt >= 0.6.8", "docopt >= 0.6.8",
"jester >= 0.5.0", "jester >= 0.5.0",
"uuids >= 0.1.10" "uuids >= 0.1.10",
"zero_functional"
] ]
# Dependencies from git.jdb-software.com/nim-jdb/packages # Dependencies from git.jdb-software.com/nim-jdb/packages
requires @[ requires @[
"cliutils >= 0.6.4", "cliutils >= 0.8.1",
"langutils >= 0.4.0", "langutils >= 0.4.0",
"timeutils >= 0.4.0", "timeutils >= 0.5.4",
"data_uri > 1.0.0", "data_uri > 1.0.0",
"https://git.jdb-software.com/jdb/update-nim-package-version >= 0.2.0" "https://git.jdb-software.com/jdb/update-nim-package-version >= 0.2.0"
] ]

View File

@ -1,8 +1,8 @@
## Personal Issue Tracker CLI interface ## Personal Issue Tracker CLI interface
## ==================================== ## ====================================
import std/algorithm, std/logging, std/options, std/os, std/sequtils, import std/[algorithm, logging, options, os, sequtils, wordwrap, tables,
std/wordwrap, std/tables, std/terminal, std/times, std/unicode terminal, times, unicode]
import cliutils, data_uri, docopt, json, timeutils, uuids import cliutils, data_uri, docopt, json, timeutils, uuids
from nre import re from nre import re
@ -51,7 +51,7 @@ proc getIssueContextDisplayName(ctx: CliContext, context: string): string =
else: return context.capitalize() else: return context.capitalize()
return ctx.contexts[context] return ctx.contexts[context]
proc formatIssue(ctx: CliContext, issue: Issue): string = proc formatIssue*(issue: Issue): string =
result = ($issue.id).withColor(fgBlack, true) & "\n"& result = ($issue.id).withColor(fgBlack, true) & "\n"&
issue.summary.withColor(fgWhite) & "\n" issue.summary.withColor(fgWhite) & "\n"
@ -70,10 +70,9 @@ proc formatIssue(ctx: CliContext, issue: Issue): string =
result &= termReset result &= termReset
proc formatSectionIssue( proc formatSectionIssue*(
ctx: CliContext,
issue: Issue, issue: Issue,
width: int, width: int = 80,
indent = "", indent = "",
verbose = false): string = verbose = false): string =
@ -135,12 +134,15 @@ proc formatSectionIssue(
result &= termReset result &= termReset
proc formatSectionIssueList(ctx: CliContext, issues: seq[Issue], width: int, proc formatSectionIssueList*(
indent: string, verbose: bool): string = issues: seq[Issue],
width: int = 80,
indent: string = "",
verbose: bool = false): string =
result = "" result = ""
for i in issues: for i in issues:
var issueText = ctx.formatSectionIssue(i, width, indent, verbose) var issueText = formatSectionIssue(i, width, indent, verbose)
result &= issueText & "\n" result &= issueText & "\n"
proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState, 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) & ":" & indent & ctx.getIssueContextDisplayName(context) & ":" &
termReset & "\n\n" termReset & "\n\n"
result &= ctx.formatSectionIssueList(ctxIssues, innerWidth - 2, indent & " ", verbose) result &= formatSectionIssueList(ctxIssues, innerWidth - 2, indent & " ", verbose)
result &= "\n" result &= "\n"
else: result &= ctx.formatSectionIssueList(issues, innerWidth, indent, verbose) else: result &= formatSectionIssueList(issues, innerWidth, indent, verbose)
proc loadIssues(ctx: CliContext, state: IssueState) = proc loadIssues(ctx: CliContext, state: IssueState) =
ctx.issues[state] = loadIssues(ctx.cfg.tasksDir, state) ctx.issues[state] = loadIssues(ctx.cfg.tasksDir, state)
@ -224,7 +226,7 @@ proc edit(issue: Issue) =
# Try to parse the newly-edited issue to make sure it was successful. # Try to parse the newly-edited issue to make sure it was successful.
let editedIssue = loadIssue(issue.filepath) let editedIssue = loadIssue(issue.filepath)
editedIssue.store() editedIssue.store()
except: except CatchableError:
fatal "updated issue is invalid (ignoring edits): \n\t" & fatal "updated issue is invalid (ignoring edits): \n\t" &
getCurrentExceptionMsg() getCurrentExceptionMsg()
issue.store() issue.store()
@ -379,7 +381,7 @@ when isMainModule:
ctx.cfg.tasksDir.store(issue, state) ctx.cfg.tasksDir.store(issue, state)
stdout.writeLine ctx.formatIssue(issue) stdout.writeLine formatIssue(issue)
elif args["reorder"]: elif args["reorder"]:
ctx.reorder(parseEnum[IssueState]($args["<state>"])) ctx.reorder(parseEnum[IssueState]($args["<state>"]))
@ -390,7 +392,7 @@ when isMainModule:
var stateOption = none(IssueState) var stateOption = none(IssueState)
try: stateOption = some(parseEnum[IssueState](editRef)) try: stateOption = some(parseEnum[IssueState](editRef))
except: discard except CatchableError: discard
if stateOption.isSome: if stateOption.isSome:
let state = stateOption.get let state = stateOption.get
@ -442,9 +444,9 @@ when isMainModule:
issue["completed"] = getTime().local.formatIso8601 issue["completed"] = getTime().local.formatIso8601
if issue.hasProp("recurrence") and issue.getRecurrence.isSome: if issue.hasProp("recurrence") and issue.getRecurrence.isSome:
let nextIssue = ctx.cfg.tasksDir.nextRecurrence(issue.getRecurrence.get, issue) let nextIssue = ctx.cfg.tasksDir.nextRecurrence(issue.getRecurrence.get, issue)
ctx.cfg.tasksDir.store(nextIssue, Todo) ctx.cfg.tasksDir.store(nextIssue, TodoToday)
info "created the next recurrence:" info "created the next recurrence:"
stdout.writeLine ctx.formatIssue(nextIssue) stdout.writeLine formatIssue(nextIssue)
issue.changeState(ctx.cfg.tasksDir, targetState) issue.changeState(ctx.cfg.tasksDir, targetState)
@ -543,8 +545,12 @@ when isMainModule:
if args["contexts"]: listContexts = true if args["contexts"]: listContexts = true
elif args["<stateOrId>"]: elif args["<stateOrId>"]:
try: statesOption = some(args["<stateOrId>"].mapIt(parseEnum[IssueState]($it))) try:
except: issueIdsOption = some(args["<stateOrId>"].mapIt($it)) statesOption =
some(args["<stateOrId>"].
mapIt(parseEnum[IssueState]($it)))
except CatchableError:
issueIdsOption = some(args["<stateOrId>"].mapIt($it))
# List the known contexts # List the known contexts
if listContexts: if listContexts:
@ -567,7 +573,7 @@ when isMainModule:
elif issueIdsOption.isSome: elif issueIdsOption.isSome:
for issueId in issueIdsOption.get: for issueId in issueIdsOption.get:
let issue = ctx.cfg.tasksDir.loadIssueById(issueId) let issue = ctx.cfg.tasksDir.loadIssueById(issueId)
stdout.writeLine ctx.formatIssue(issue) stdout.writeLine formatIssue(issue)
# List all issues # List all issues
else: else:
@ -608,7 +614,23 @@ when isMainModule:
try: write(propOut, decodeDataUri(issue[$(args["<propName>"])])) try: write(propOut, decodeDataUri(issue[$(args["<propName>"])]))
finally: close(propOut) finally: close(propOut)
except: elif args["show-dupes"]:
ctx.loadAllIssues()
var idsToPaths = newTable[string, var seq[string]]()
for (state, issues) in pairs(ctx.issues):
for issue in issues:
let issueId = $issue.id
if idsToPaths.hasKey(issueId): idsToPaths[issueId].add(issue.filepath)
else: idsToPaths[issueId] = @[issue.filepath]
for (issueId, issuePaths) in pairs(idsToPaths):
if issuePaths.len < 2: continue
stdout.writeLine(issueId & ":\p " & issuePaths.join("\p ") & "\p\p")
except CatchableError:
fatal getCurrentExceptionMsg() fatal getCurrentExceptionMsg()
debug getCurrentException().getStackTrace()
#raise getCurrentException() #raise getCurrentException()
quit(QuitFailure) quit(QuitFailure)

View File

@ -1,4 +1,4 @@
const PIT_VERSION* = "4.22.1" const PIT_VERSION* = "4.24.0"
const USAGE* = """Usage: const USAGE* = """Usage:
pit ( new | add) <summary> [<state>] [options] pit ( new | add) <summary> [<state>] [options]
@ -14,6 +14,7 @@ const USAGE* = """Usage:
pit ( delete | rm ) <id>... [options] pit ( delete | rm ) <id>... [options]
pit add-binary-property <id> <propName> <propSource> [options] pit add-binary-property <id> <propName> <propSource> [options]
pit get-binary-property <id> <propName> <propDest> [options] pit get-binary-property <id> <propName> <propDest> [options]
pit show-dupes
pit help [options] pit help [options]
Options: Options:
@ -100,7 +101,9 @@ Issue Properties:
created created
If present, expected to be an ISO 8601-formatted date that represents the If present, expected to be an ISO 8601-formatted date that represents the
time when the issue was created. time when the issue was created. E.g.:
created: 2023-07-13T13:28:41-05:00
completed completed
@ -109,12 +112,16 @@ Issue Properties:
property automatically when you use the "done" command, and can filter on property automatically when you use the "done" command, and can filter on
this value. this value.
completed: 2023-04-27T11:52:28-05:00
context context
Allows issues to be organized into contexts. The -c option is short-hand Allows issues to be organized into contexts. The -c option is short-hand
for '-p context:<context-name>' and the 'list contexts' command will show for '-p context:<context-name>' and the 'list contexts' command will show
all values of 'context' set in existing issues. all values of 'context' set in existing issues.
context: family
delegated-to delegated-to
When an issue now belongs to someone else, but needs to be monitored for When an issue now belongs to someone else, but needs to be monitored for
@ -122,17 +129,23 @@ Issue Properties:
note how it has been delegated. When present PIT will prepend this value note how it has been delegated. When present PIT will prepend this value
to the issue summary with an accent color. to the issue summary with an accent color.
delegated-to: Bob Ross
hide-until hide-until
When present, expected to be an ISO 8601-formatted date and used to 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. supress the display of the issue until on or after the given date.
hide-until: 2024-01-01T13:45:00-05:00
pending pending
When an issue is blocked by a third party, this property can be used to 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 capture details about the dependency When present PIT will display this
value after the issue summary. value after the issue summary.
pending: Results of WCAG analysis.
recurrence recurrence
When an issue is moved to the "done" state, if the issue has a valid When an issue is moved to the "done" state, if the issue has a valid
@ -143,7 +156,7 @@ Issue Properties:
A valid recurrence value has a time value and optionally has an source A valid recurrence value has a time value and optionally has an source
issue ID. For example: issue ID. For example:
every 5 days, 10a544 recurrence: every 5 days, 10a544
The first word, "every", is expected to be either "every" or "after". The first word, "every", is expected to be either "every" or "after".
@ -161,15 +174,39 @@ Issue Properties:
Examples: Examples:
every day every day
every 2 days every 2 days
after 2 days after 2 days
every week every week
after 12 hours after 12 hours
every 2 weeks, 10a544 every 2 weeks, 10a544
relations
Used to store information about relationships between issues. PIT treats
all relations as bi-directional and will update related tickets as
necessary to maintain the integrity of the links.
Relations are captured as <relation-type> <related-issue-id>. Multiple
relations can be separated by ",". The <related-issue-id> must be a unique
issue ID or ID prefix (in the case of multiple matching issues, the first
found will be used with no guarantee on ordering). Valid value pairs for
<relation-type> are:
- child-of / parent-of
- related-to (default)
- blocks / blocked-by
- follows / precedes
- caused / caused-by
Examples:
relation: child-of fb3e63, blocked-by 2b71c1
relation: 2b71c1, 8f2b4c, follows 184dc6
relation: relates-to 2b71c1
tags tags
If present, expected to be a comma-delimited list of text tags. The -g If present, expected to be a comma-delimited list of text tags. The -g
option is a short-hand for '-p tags:<tags-value>'. option is a short-hand for '-p tags:<tags-value>'.
""" """

View File

@ -1,8 +1,9 @@
import std/json, std/logging, std/options, std/os, std/sequtils, std/strformat, import std/[json, logging, options, os, strformat, strutils, tables, times]
std/strutils, std/tables, std/times import cliutils, docopt, langutils, uuids, zero_functional
import cliutils, docopt, langutils, timeutils, uuids
import nre except toSeq import nre except toSeq
import timeutils except `>`
from sequtils import deduplicate, toSeq
type type
Issue* = ref object Issue* = ref object
@ -10,7 +11,9 @@ type
filepath*: string filepath*: string
summary*, details*: string summary*, details*: string
properties*: TableRef[string, string] properties*: TableRef[string, string]
relations*: seq[Relation]
tags*: seq[string] tags*: seq[string]
state*: IssueState
IssueState* = enum IssueState* = enum
Current = "current", Current = "current",
@ -38,7 +41,21 @@ type
interval*: TimeInterval interval*: TimeInterval
isFromCompletion*: bool isFromCompletion*: bool
Relation* = tuple[rel: RelationType, id: UUID]
RelationType* = enum
ParentOf = "parent-of",
ChildOf = "child-of",
RelatedTo = "related-to",
Blocks = "blocks",
BlockedBy = "blocked-by",
Follow = "follows",
FollowedBy = "followed-by",
Caused = "caused",
CausedBy = "caused-by"
const DONE_FOLDER_FORMAT* = "yyyy-MM" 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 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 RECURRENCE_PATTERN = re"(every|after) ((\d+) )?((hour|day|week|month|year)s?)(, ([0-9a-fA-F]+))?"
@ -165,11 +182,16 @@ proc parseDate*(d: string): DateTime =
var errMsg = "" var errMsg = ""
for df in DATE_FORMATS: for df in DATE_FORMATS:
try: return d.parse(df) try: return d.parse(df)
except: except CatchableError:
errMsg &= "\n\tTried " & df & " with " & d errMsg &= "\n\tTried " & df & " with " & d
continue continue
raise newException(ValueError, "Unable to parse input as a date: " & d & errMsg) raise newException(ValueError, "Unable to parse input as a date: " & d & errMsg)
proc parseRelation*(relStr: string): Relation =
let parts = relStr.split({' '})
if parts.len == 1: result = (RelatedTo, parseUUID(parts[0]))
else: result = (parseEnum[RelationType](parts[0]), parseUUID(parts[1]))
## Parse and format issues ## Parse and format issues
proc fromStorageFormat*(id: string, issueTxt: string): Issue = proc fromStorageFormat*(id: string, issueTxt: string): Issue =
type ParseState = enum ReadingSummary, ReadingProps, ReadingDetails type ParseState = enum ReadingSummary, ReadingProps, ReadingDetails
@ -177,6 +199,7 @@ proc fromStorageFormat*(id: string, issueTxt: string): Issue =
result = Issue( result = Issue(
id: parseUUID(id), id: parseUUID(id),
properties: newTable[string,string](), properties: newTable[string,string](),
relations: @[],
tags: @[]) tags: @[])
var parseState = ReadingSummary var parseState = ReadingSummary
@ -200,13 +223,16 @@ proc fromStorageFormat*(id: string, issueTxt: string): Issue =
parseState = ReadingDetails parseState = ReadingDetails
continue continue
let parts = line.split({':'}, 1) --> map(it.strip())
let parts = line.split({':'}, 1).mapIt(it.strip())
if parts.len != 2: if parts.len != 2:
raise newException(ValueError, "unable to parse property line: " & line) raise newException(ValueError, "unable to parse property line: " & line)
# Take care of special properties: `tags` # Take care of special properties: `tags` and `relations`
if parts[0] == "tags": result.tags = parts[1].split({','}).mapIt(it.strip()) if parts[0] == "tags":
result.tags = parts[1].split({','}) --> map(it.strip())
elif parts[0] == "relations":
result.relations = parts[1].split({','}) -->
map(parseRelation(it.strip))
else: result[parts[0]] = parts[1] else: result[parts[0]] = parts[1]
of ReadingDetails: of ReadingDetails:
@ -216,12 +242,22 @@ proc fromStorageFormat*(id: string, issueTxt: string): Issue =
proc toStorageFormat*(issue: Issue, withComments = false): string = proc toStorageFormat*(issue: Issue, withComments = false): string =
var lines: seq[string] = @[] var lines: seq[string] = @[]
if withComments: lines.add("# Summary (one line):") if withComments: lines.add("# Summary (one line):")
lines.add(issue.summary) lines.add(issue.summary)
if withComments: lines.add("# Properties (\"key:value\" per line):") if withComments: lines.add("# Properties (\"key:value\" per line):")
issue.properties["last-updated"] = now().format(ISO8601_MS)
for key, val in issue.properties: for key, val in issue.properties:
if not val.isEmptyOrWhitespace: lines.add(key & ": " & val) if not val.isEmptyOrWhitespace: lines.add(key & ": " & val)
if issue.tags.len > 0: lines.add("tags: " & issue.tags.join(",")) if issue.tags.len > 0: lines.add("tags: " & issue.tags.join(","))
if issue.relations.len > 0:
lines.add("relations: " &
(issue.relations --> map(it.rel & " " & it.id)).
join(', '))
if not isEmptyOrWhitespace(issue.details) or withComments: if not isEmptyOrWhitespace(issue.details) or withComments:
if withComments: lines.add("# Details go below the \"--------\"") if withComments: lines.add("# Details go below the \"--------\"")
lines.add("--------") lines.add("--------")
@ -234,6 +270,11 @@ proc loadIssue*(filePath: string): Issue =
result = fromStorageFormat(splitFile(filePath).name, readFile(filePath)) result = fromStorageFormat(splitFile(filePath).name, readFile(filePath))
result.filepath = 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 = proc loadIssueById*(tasksDir, id: string): Issue =
for path in walkDirRec(tasksDir): for path in walkDirRec(tasksDir):
if path.splitFile.name.startsWith(id): if path.splitFile.name.startsWith(id):
@ -273,10 +314,10 @@ proc loadIssues*(path: string): seq[Issue] =
let orderedIds = let orderedIds =
if fileExists(orderFile): if fileExists(orderFile):
toSeq(orderFile.lines) (orderFile.lines.toSeq -->
.mapIt(it.split(' ')[0]) map(it.split(' ')[0]).
.deduplicate filter(not it.startsWith("> ") and not it.isEmptyOrWhitespace)).
.filterIt(not it.startsWith("> ") and not it.isEmptyOrWhitespace) deduplicate()
else: newSeq[string]() else: newSeq[string]()
type TaggedIssue = tuple[issue: Issue, ordered: bool] type TaggedIssue = tuple[issue: Issue, ordered: bool]
@ -318,6 +359,7 @@ proc changeState*(issue: Issue, tasksDir: string, newState: IssueState) =
if newState == Done: issue.setDateTime("completed", getTime().local) if newState == Done: issue.setDateTime("completed", getTime().local)
tasksDir.store(issue, newState) tasksDir.store(issue, newState)
if oldFilePath != issue.filepath: removeFile(oldFilepath) if oldFilePath != issue.filepath: removeFile(oldFilepath)
issue.state = newState
proc delete*(issue: Issue) = removeFile(issue.filepath) proc delete*(issue: Issue) = removeFile(issue.filepath)
@ -332,6 +374,7 @@ proc nextRecurrence*(tasksDir: string, rec: Recurrence, defaultIssue: Issue): Is
result = Issue( result = Issue(
id: genUUID(), id: genUUID(),
state: baseIssue.state,
summary: baseIssue.summary, summary: baseIssue.summary,
properties: newProps, properties: newProps,
tags: baseIssue.tags) tags: baseIssue.tags)
@ -354,36 +397,38 @@ proc nextRecurrence*(tasksDir: string, rec: Recurrence, defaultIssue: Issue): Is
## Utilities for working with issue collections. ## Utilities for working with issue collections.
proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] = proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
result = issues var f: seq[Issue] = issues
for k,v in filter.properties: 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: for k,v in filter.exclProperties:
result = result.filter(proc (iss: Issue): bool = f = f --> filter(not (it.hasProp(k) and v.contains(it[k])))
not iss.hasProp(k) or
not v.anyIt(it == iss[k])
)
if filter.completedRange.isSome: if filter.completedRange.isSome:
let range = filter.completedRange.get let range = filter.completedRange.get
result = result.filterIt( f = f --> filter(
not it.hasProp("completed") or not it.hasProp("completed") or
it.getDateTime("completed").between(range.b, range.e)) it.getDateTime("completed").between(range.b, range.e))
if filter.summaryMatch.isSome: if filter.summaryMatch.isSome:
let p = filter.summaryMatch.get 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: if filter.fullMatch.isSome:
let p = filter.fullMatch.get 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: for tagLent in filter.hasTags:
result = result.filterIt(it.tags.find(tag) >= 0) let tag = tagLent
f = f --> filter(it.tags.find(tag) >= 0)
for exclTag in filter.exclTags: for exclTagLent in filter.exclTags:
result = result.filterIt(it.tags.find(exclTag) < 0) 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*( proc find*(
issues: TableRef[IssueState, seq[Issue]], issues: TableRef[IssueState, seq[Issue]],
@ -395,14 +440,12 @@ proc find*(
### Configuration utilities ### Configuration utilities
proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitConfig = proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitConfig =
let pitrcLocations = @[ var pitrcFilename: string
if args["--config"]: $args["--config"] else: "",
".pitrc", $getEnv("PITRC"), $getEnv("HOME") & "/.pitrc"]
var pitrcFilename: string = try:
foldl(pitrcLocations, if len(a) > 0: a elif fileExists(b): b else: "") pitrcFilename = findConfigFile(".pitrc",
if args["--config"]: @[$args["--config"]] else: @[])
if not fileExists(pitrcFilename): except ValueError:
warn "could not find .pitrc file: " & pitrcFilename warn "could not find .pitrc file: " & pitrcFilename
if isEmptyOrWhitespace(pitrcFilename): if isEmptyOrWhitespace(pitrcFilename):
pitrcFilename = $getEnv("HOME") & "/.pitrc" pitrcFilename = $getEnv("HOME") & "/.pitrc"
@ -410,25 +453,19 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
try: try:
cfgFile = open(pitrcFilename, fmWrite) cfgFile = open(pitrcFilename, fmWrite)
cfgFile.write("{\"tasksDir\": \"/path/to/tasks\"}") cfgFile.write("{\"tasksDir\": \"/path/to/tasks\"}")
except: warn "could not write default .pitrc to " & pitrcFilename except CatchableError: warn "could not write default .pitrc to " & pitrcFilename
finally: close(cfgFile) finally: close(cfgFile)
var cfgJson: JsonNode debug "loading config from '$#'" % [pitrcFilename]
try: cfgJson = parseFile(pitrcFilename) let cfg = initCombinedConfig(pitrcFilename, args)
except: raise newException(IOError,
"unable to read config file: " & pitrcFilename &
"\x0D\x0A" & getCurrentExceptionMsg())
let cfg = CombinedConfig(docopt: args, json: cfgJson)
result = PitConfig( result = PitConfig(
cfg: cfg, cfg: cfg,
contexts: newTable[string,string](), contexts: newTable[string,string](),
tasksDir: cfg.getVal("tasks-dir", "")) tasksDir: cfg.getVal("tasks-dir", ""))
if cfgJson.hasKey("contexts"): for k, v in cfg.getJson("contexts", newJObject()):
for k, v in cfgJson["contexts"]: result.contexts[k] = v.getStr()
result.contexts[k] = v.getStr()
if isEmptyOrWhitespace(result.tasksDir): if isEmptyOrWhitespace(result.tasksDir):
raise newException(Exception, "no tasks directory configured") raise newException(Exception, "no tasks directory configured")