Skip to content

Commit

Permalink
Add time tracking hooks
Browse files Browse the repository at this point in the history
Implemented two new hooks to track the duration a task was active. Both
hooks are written in Python 3 and require a Taskwarrior version `>=2.4`.

> With `totalactivetime` UDA

The first hook makes use of a new UDA called `totalactivetime` of type
`duration` which stores the total time in seconds. Every time a task
gets stopped the value gets added to the old value. To simplify the
communication with the Taskwarrior Hook v2 API (1) the hook should make
use of the "taskw" (2) package which provides the bindings for Python.

NOTE: This requires "taskw" (2) as a direct dependency and must be
installed on the target system!
The package is available from the AUR (3).

> Taskwarrior integration

The second hook should be a fort from the official Taskwarrior
`on-modify.taskwarrior` hook (4). It comes bundled with Taskwarrior,
but can not be used via symlink, because the file is owned by the
`root` user which prevents the script from being executed when symlinked.
The usage of the hook is also documented in the official Timewarrior docs (5).

References:

  (1) https://taskwarrior.org/docs/hooks2.html
  (2) https://pypi.python.org/pypi/taskw
  (3) https://aur.archlinux.org/packages/python-taskw
  (4) https://github.com/GothenburgBitFactory/timewarrior/blob/dev/ext/on-modify.timewarrior
  (5) https://taskwarrior.org/docs/timewarrior/taskwarrior.html

Closes GH-108
  • Loading branch information
arcticicestudio committed Apr 7, 2018
1 parent c6534e6 commit 2e541f5
Show file tree
Hide file tree
Showing 4 changed files with 220 additions and 3 deletions.
68 changes: 68 additions & 0 deletions snowblocks/taskwarrior/hooks/on-modify-track-timewarrior.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-

# Copyright (c) 2016-present Arctic Ice Studio <[email protected]>
# Copyright (c) 2016-present Sven Greb <[email protected]>

# Project: igloo
# Repository: https://github.com/arcticicestudio/igloo
# License: MIT
# References:
# https://taskwarrior.org/docs
# https://taskwarrior.org/docs/timewarrior
# timew(1)
# task(1)

"""A Taskwarrior hook to track the time of a active task with Taskwarrior.
This hook will extract all of the following for use as Timewarrior tags:
* UUID
* Project
* Tags
* Description
* UDAs
Note:
This hook requires Python 3 and is only compatible with Taskwarrior version greater or equal to 2.4!
This hook is a fork from the `official on-modify.timewarrior hook`_.
.. _`official on-modify.timewarrior hook`:
https://github.com/GothenburgBitFactory/timewarrior/blob/dev/ext/on-modify.timewarrior
"""

from sys import stdin
from os import system
from json import loads, dumps

# Make no changes to the task, simply observe.
old = loads(stdin.readline())
new = loads(stdin.readline())
print(dumps(new))

# Extract attributes for use as tags.
tags = [new["description"]]

if "project" in new:
project = new["project"]
tags.append(project)
if "." in project:
tags.extend([tag for tag in project.split(".")])

if "tags" in new:
tags.extend(new["tags"])

combined = " ".join(["'%s'" % tag for tag in tags]).encode("utf-8").strip()

# Task has been started.
if "start" in new and not "start" in old:
system("timew start " + combined.decode() + " :yes")

# Task has been stopped.
elif not "start" in new and "start" in old:
system("timew stop " + combined.decode() + " :yes")

# Any task that is active, with a non-pending status should not be tracked.
elif "start" in new and new["status"] != "pending":
system("timew stop " + combined.decode() + " :yes")
135 changes: 135 additions & 0 deletions snowblocks/taskwarrior/hooks/on-modify-track-total-active-time.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-

# Copyright (c) 2016-present Arctic Ice Studio <[email protected]>
# Copyright (c) 2016-present Sven Greb <[email protected]>

# Project: igloo
# Repository: https://github.com/arcticicestudio/igloo
# License: MIT
# References:
# https://taskwarrior.org/docs
# task(1)

"""A Taskwarrior hook to track the total active time of a task.
The tracked time is stored in a UDA task duration attribute named ``totalactivetime`` of type ``duration`` holding the total number of seconds the task was
active. The tracked time can then be included in any report by adding the ``totalactivetime`` column.
By default, this plugin allows to have one task active at a time. This can be changed by setting ``max_active_tasks`` in ``.taskrc`` to a value greater than
``1``.
Note:
This hook requires Python 3 and the `taskw`_ package to be installed which provides the python bindings for Taskwarrior!
Also note that this hook is only compatible with Taskwarrior version greater or equal to 2.4!
This hook is a fork from `kostajh/taskwarrior-time-tracking-hook`_
.. _taskw:
https://pypi.python.org/pypi/taskw
.. _kostajh/taskwarrior-time-tracking-hook:
https://github.com/kostajh/taskwarrior-time-tracking-hook
"""

import datetime
import json
import re
import sys
import subprocess
from taskw import TaskWarrior
from typing import TypeVar

TIME_FORMAT = "%Y%m%dT%H%M%SZ"
UDA_KEY = "totalactivetime"

w = TaskWarrior()
config = w.load_config()
if "max_active_tasks" in config:
MAX_ACTIVE = int(config["max_active_tasks"])
else:
MAX_ACTIVE = 1

"""Compiled regular expression for the duration as ISO-8601 formatted string."""
ISO8601DURATION = re.compile("P((\d*)Y)?((\d*)M)?((\d*)D)?T((\d*)H)?((\d*)M)?((\d*)S)?")

"""The duration type either as integer (in seconds), as ISO-8601 formatted string ("PT1H10M31S") or the seconds suffixed with "seconds"."""
DurationType = TypeVar("DurationType", str, int)


def duration_str_to_time_delta(duration_str: DurationType) -> datetime.timedelta:
"""Converts duration string into a timedelta object.
:param duration_str: The duration
:return: The duration as timedelta object
"""
if duration_str.startswith("P"):
match = ISO8601DURATION.match(duration_str)
if match:
year = match.group(2)
month = match.group(4)
day = match.group(6)
hour = match.group(8)
minute = match.group(10)
second = match.group(12)
value = 0
if second:
value += int(second)
if minute:
value += int(minute) * 60
if hour:
value += int(hour) * 3600
if day:
value += int(day) * 3600 * 24
if month:
# Assume a month is 30 days for now.
value += int(month) * 3600 * 24 * 30
if year:
# Assume a year is 365 days for now.
value += int(year) * 3600 * 24 * 365
else:
value = int(duration_str)
elif duration_str.endswith("seconds"):
value = int(duration_str.rstrip("seconds"))
else:
value = int(duration_str)
return datetime.timedelta(seconds=value)


def main():
original = json.loads(sys.stdin.readline())
modified = json.loads(sys.stdin.readline())

# An active task has just been started.
if "start" in modified and "start" not in original:
# Prevent this task from starting if "task +ACTIVE count" is greater than "MAX_ACTIVE".
p = subprocess.Popen(["task", "+ACTIVE", "status:pending", "count", "rc.verbose:off"], stdout=subprocess.PIPE)
out, err = p.communicate()
count = int(out.rstrip())
if count >= MAX_ACTIVE:
print("Only %d task(s) can be active at a time. "
"See 'max_active_tasks' in .taskrc." % MAX_ACTIVE)
sys.exit(1)

# An active task has just been stopped.
if "start" in original and "start" not in modified:
# Calculate the elapsed time.
start = datetime.datetime.strptime(original["start"], TIME_FORMAT)
end = datetime.datetime.utcnow()

if UDA_KEY not in modified:
modified[UDA_KEY] = 0

this_duration = (end - start)
total_duration = (this_duration + duration_str_to_time_delta(str(modified[UDA_KEY])))
print("Total Time Tracked: %s (%s in this instance)" % (total_duration, this_duration))
modified[UDA_KEY] = str(int(total_duration.days * (60 * 60 * 24) + total_duration.seconds)) + "seconds"

return json.dumps(modified, separators=(",", ":"))


def cmdline():
sys.stdout.write(main())


if __name__ == "__main__":
cmdline()
10 changes: 10 additions & 0 deletions snowblocks/taskwarrior/snowblock.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@
"archbook": "nord.theme",
"igloo": "nord.theme"
}
},
"~/.task/hooks/on-modify-track-timewarrior.py": {
"create": true,
"force": true,
"path": "hooks/on-modify-track-timewarrior.py"
},
"~/.task/hooks/on-modify-track-total-active-time.py": {
"create": true,
"force": true,
"path": "hooks/on-modify-track-total-active-time.py"
}
}
}
Expand Down
10 changes: 7 additions & 3 deletions snowblocks/taskwarrior/taskrc
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
data.location=~/.task

complete.all.tags=true
default.command=list
default.command=stat
editor=vim
list.all.projects=true
list.all.tags=true
Expand Down Expand Up @@ -62,12 +62,16 @@ uda.ghi.type=numeric
uda.ghi.label=GitHub Issue
uda.ghi.default=

uda.totalactivetime.type=duration
uda.totalactivetime.label=Total active time
uda.totalactivetime.values=

#+---------+
#+ Reports +
#+---------+
report.stat.description=Optimized and extended status report
report.stat.columns=id,start.age,entry.age,depends.indicator,priority,project,tags,recur.indicator,scheduled,scheduled.relative,due,due.relative,until.remaining,description.count,urgency
report.stat.labels=ID,Active,Age,D,P,Project,Tags,R,Sch,,Due,,Until,Desc,Urg
report.stat.columns=id,start.age,entry.age,totalactivetime,depends.indicator,priority,project,tags,recur.indicator,scheduled,scheduled.relative,due,due.relative,until.remaining,description.count,urgency
report.stat.labels=ID,Active,Age,Time,D,P,Project,Tags,R,Sch,,Due,,Until,Desc,Urg
report.stat.filter=status:pending
report.stat.sort=start-,due+,project+,urgency-

Expand Down

0 comments on commit 2e541f5

Please sign in to comment.