Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement basic clocking support #302

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion doc/orgguide.txt
Original file line number Diff line number Diff line change
Expand Up @@ -928,7 +928,28 @@ Deadlines and scheduling~

------------------------------------------------------------------------------
Clocking work time~
Not yet implemented in vim-orgmode~

Org mode allows you to clock the time you spend on specific tasks in a
project.

orgmode-<LocalLeader>ci
<LocalLeader>ci Start the clock on the current item (clock-in). This
inserts the CLOCK keyword together with a timestamp.
Currently vim-orgmode will insert LOGBOOK drawer even
if there is only one clock line

<LocalLeader>co Stop the clock (clock-out) in the current heading.
This inserts another timestamp at the location of the
last started clock in the heading. It also directly
computes the resulting time in inserts it after the
time range as ‘=> HH:MM’

<LocalLeader>cu Update duration of the current clock line after the
the timestamps were changed manually

<LocalLeader>ct Display total time spent in the current heading. This
command will recursively sum times in the nested
headings too

==============================================================================
CAPTURE - REFILE - ARCHIVE *orgguide-capture*
Expand Down
2 changes: 1 addition & 1 deletion ftplugin/org.vim
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ let g:loaded_org = 1

" Default org plugins that will be loaded (in the given order) {{{2
if ! exists('g:org_plugins') && ! exists('b:org_plugins')
let g:org_plugins = ['ShowHide', '|', 'Navigator', 'EditStructure', 'EditCheckbox', '|', 'Hyperlinks', '|', 'Todo', 'TagsProperties', 'Date', 'Agenda', 'Misc', '|', 'Export']
let g:org_plugins = ['ShowHide', '|', 'Navigator', 'EditStructure', 'EditCheckbox', '|', 'Hyperlinks', '|', 'Todo', 'TagsProperties', 'Date', 'Agenda', 'Misc', '|', 'Export', 'LoggingWork']
endif

" Default org plugin settings {{{2
Expand Down
22 changes: 21 additions & 1 deletion ftplugin/orgmode/liborgmode/headings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
import vim
from orgmode.liborgmode.base import MultiPurposeList, flatten_list, Direction, get_domobj_range
from orgmode.liborgmode.orgdate import OrgTimeRange
from orgmode.liborgmode.orgdate import get_orgdate
from orgmode.liborgmode.orgdate import get_orgdate, _text2orgdate
from orgmode.liborgmode.checkboxes import Checkbox, CheckboxList
from orgmode.liborgmode.logbook import ClockLine, Logbook
from orgmode.liborgmode.dom_obj import DomObj, DomObjList, REGEX_SUBTASK, REGEX_SUBTASK_PERCENT, REGEX_HEADING, REGEX_TAG, REGEX_TODO

from orgmode.py3compat.xrange_compatibility import *
Expand Down Expand Up @@ -62,6 +63,8 @@ def __init__(self, level=1, title=u'', tags=None, todo=None, body=None, active_d
self._checkboxes = CheckboxList(obj=self)
self._cached_checkbox = None

self._logbook = Logbook(obj=self)

def __unicode__(self):
res = u'*' * self.level
if self.todo:
Expand Down Expand Up @@ -333,6 +336,19 @@ def init_checkbox(_c):

return self

def init_logbook(self):
heading_end = self.start + len(self) - 1
self.logbook.clear()
for i, line in enumerate(self.document._content[self.start + 1:heading_end + 1]):
line_date = _text2orgdate(line)
if line_date is not None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if line_date: is enough or if line_date is None: continue with unindenting the current if block

clock_line = ClockLine(
date=line_date,
orig_start=self.start + 1 + i,
level=self.level + 1,
)
self.logbook.data.append(clock_line)

def current_checkbox(self, position=None):
u""" Find the current checkbox (search backward) and return the related object
:returns: Checkbox object or None
Expand Down Expand Up @@ -674,6 +690,10 @@ def checkboxes(self, value):
def checkboxes(self):
del self.checkboxes[:]

@property
def logbook(self):
return self._logbook


class HeadingList(DomObjList):
u"""
Expand Down
54 changes: 54 additions & 0 deletions ftplugin/orgmode/liborgmode/logbook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@

from orgmode.liborgmode.dom_obj import DomObj, DomObjList
from orgmode.liborgmode.orgdate import OrgDateTime, OrgTimeRange

from orgmode.py3compat.encode_compatibility import *
from orgmode.py3compat.unicode_compatibility import *


class ClockLine(DomObj):
def __init__(self, date, orig_start=None, *args, **kwargs):
super(ClockLine, self).__init__(*args, **kwargs)
self._date = date
# Force ranges to render as []--[] even if both are on the same day
self._date.verbose = True
self._orig_start = orig_start

self._dirty_clockline = False

def __unicode__(self):
if isinstance(self.date, OrgDateTime):
return u' ' * self.level + u'CLOCK: %s' % self.date
elif isinstance(self.date, OrgTimeRange):
self._date.verbose = True
return u' ' * self.level + u'CLOCK: %s => %s' % (self.date, self.date.str_duration())
else:
raise TypeError("self.date is %s instead of OrgDateTime/OrgTimeRange, impossible" % self.date)

def __str__(self):
return u_encode(self.__unicode__())

def set_dirty(self):
self._dirty_clockline = True
super(ClockLine, self).set_dirty()

@property
def is_dirty(self):
return self._dirty_clockline

@property
def finished(self):
return isinstance(self.date, OrgTimeRange)

@property
def date(self):
return self._date

@date.setter
def date(self, new_date):
self._date = new_date
self.set_dirty()


class Logbook(DomObjList):
pass
29 changes: 28 additions & 1 deletion ftplugin/orgmode/liborgmode/orgdate.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@
_DATETIME_PASSIVE_REGEX = re.compile(
r"\[(\d\d\d\d)-(\d\d)-(\d\d) [A-Z]\w\w (\d{1,2}):(\d\d)\]", re.UNICODE)

_DATETIMERANGE_PASSIVE_REGEX = re.compile(
# <2011-09-12 Mon 10:00>--
r"\[(\d\d\d\d)-(\d\d)-(\d\d) [A-Z]\w\w (\d\d):(\d\d)\]--"
# <2011-09-12 Mon 11:00>
"\[(\d\d\d\d)-(\d\d)-(\d\d) [A-Z]\w\w (\d\d):(\d\d)\]", re.UNICODE)

# <2011-09-12 Mon>--<2011-09-13 Tue>
_DATERANGE_REGEX = re.compile(
# <2011-09-12 Mon>--
Expand Down Expand Up @@ -134,6 +140,18 @@ def _text2orgdate(string):
except BaseException:
return None

# handle passive datetime
result = _DATETIMERANGE_PASSIVE_REGEX.search(string)
if result:
try:
tmp = [int(m) for m in result.groups()]
(syear, smonth, sday, shour, smin, eyear, emonth, eday, ehour, emin) = tmp
start = datetime.datetime(syear, smonth, sday, shour, smin)
end = datetime.datetime(eyear, emonth, eday, ehour, emin)
return OrgTimeRange(False, start, end)
except BaseException:
return None

# handle passive datetime
result = _DATETIME_PASSIVE_REGEX.search(string)
if result:
Expand Down Expand Up @@ -247,6 +265,8 @@ def __init__(self, active, start, end):
self.end = end
self.active = active

self.verbose = False

def __unicode__(self):
u"""
Return a string representation.
Expand Down Expand Up @@ -275,7 +295,7 @@ def __unicode__(self):
else:
if isinstance(self.start, datetime.datetime):
# if start and end are on same the day
if self.start.year == self.end.year and\
if not self.verbose and self.start.year == self.end.year and\
self.start.month == self.end.month and\
self.start.day == self.end.day:
return u"[%s-%s]" % (
Expand All @@ -293,4 +313,11 @@ def __unicode__(self):
def __str__(self):
return u_encode(self.__unicode__())

def duration(self):
return self.end - self.start

def str_duration(self):
duration = self.duration()
hours, minutes = divmod(duration.total_seconds(), 3600)
return u'%d:%d' % (hours, minutes // 60)
# vim: set noexpandtab:
2 changes: 1 addition & 1 deletion ftplugin/orgmode/plugins/Date.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ def insert_timestamp(cls, active=True):
TODO: show fancy calendar to pick the date from.
TODO: add all modifier of orgmode.
"""
today = date.today()
today = datetime.now()
msg = u''.join([
u'Inserting ',
unicode(u_decode(today.strftime(u'%Y-%m-%d %a'))),
Expand Down
141 changes: 132 additions & 9 deletions ftplugin/orgmode/plugins/LoggingWork.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,38 @@

import vim

from datetime import *

from orgmode._vim import echo, echom, echoe, ORGMODE, apply_count, repeat
from orgmode.menu import Submenu, Separator, ActionEntry
from orgmode.menu import Submenu, Separator, ActionEntry, add_cmd_mapping_menu
from orgmode.keybinding import Keybinding, Plug, Command
from orgmode.liborgmode.headings import ClockLine
from orgmode.liborgmode.orgdate import OrgDateTime, OrgTimeRange

from orgmode.py3compat.encode_compatibility import *
from orgmode.py3compat.py_py3_string import *
from orgmode.py3compat.unicode_compatibility import *


def get_total_time(heading):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe use generators:

total = sum(clockline for clockline in heading.logbook if not clockline.finished)
return total + sum(get_total_time(child) for child in heading.logbook)

total = None
for clockline in heading.logbook:
if not clockline.finished:
continue

if total is None:
total = clockline.date.duration()
else:
total += clockline.date.duration()

for child in heading.children:
if total is None:
total = get_total_time(child)
else:
total += get_total_time(child)

return total


class LoggingWork(object):
u""" LoggingWork plugin """
Expand All @@ -26,17 +53,113 @@ def __init__(self):
self.commands = []

@classmethod
def action(cls):
u""" Some kind of action
def clock_in(cls):
d = ORGMODE.get_document()
current_heading = d.current_heading()
current_heading.init_logbook()

:returns: TODO
"""
pass
now = datetime.now()
new_clock_line = ClockLine(
date=OrgDateTime(False, now.year, now.month, now.day, now.hour, now.minute),
level=current_heading.level + 1
)
current_heading.logbook.append(new_clock_line)

# + 1 line to skip the heading itself
start = current_heading.start + 1
if len(current_heading.logbook) == 1:
vim.current.buffer[start:start] = [u' ' * current_heading.level + u':LOGBOOK:']

# For ':LOGBOOK:' line
start += 1
vim.current.buffer[start:start] = [unicode(new_clock_line)]

if len(current_heading.logbook) == 1:
start += 1
vim.current.buffer[start:start] = [u' ' * current_heading.level + u':END:']

@classmethod
def clock_out(cls):
d = ORGMODE.get_document()
current_heading = d.current_heading()
current_heading.init_logbook()

if not current_heading.logbook:
return

for last_entry in current_heading.logbook:
if not last_entry.finished:
break
else:
return

end_date = datetime.now()
duration = OrgTimeRange(False, last_entry.date, end_date)
last_entry.date = duration

d.write_clockline(last_entry)

@classmethod
def clock_update(cls):
d = ORGMODE.get_document()
current_heading = d.current_heading()
current_heading.init_logbook()

if not current_heading.logbook:
return

position = vim.current.window.cursor[0] - 1
# -2 to skip heading itself and :LOGBOOK:
clockline_index = position - current_heading.start - 2

if clockline_index < 0 or clockline_index >= len(current_heading.logbook):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

len(current_heading.logbook) <= clockline_index < 0

return

current_heading.logbook[clockline_index].set_dirty()

d.write_clockline(current_heading.logbook[clockline_index])

@classmethod
def clock_total(cls):
d = ORGMODE.get_document()
current_heading = d.current_heading()
current_heading.init_logbook()

total = get_total_time(current_heading)

if total is not None:
hours, minutes = divmod(total.total_seconds(), 3600)
echo(u'Total time spent in this heading: %d:%d' % (hours, minutes // 60))

def register(self):
u"""
Registration of plugin. Key bindings and other initialization should be done.
"""
# an Action menu entry which binds "keybinding" to action ":action"
self.commands.append(Command(u'OrgLoggingRecordDoneTime', u'%s ORGMODE.plugins[u"LoggingWork"].action()' % VIM_PY_CALL))
self.menu + ActionEntry(u'&Record DONE time', self.commands[-1])
add_cmd_mapping_menu(
self,
name=u'OrgLoggingClockIn',
function=u'%s ORGMODE.plugins[u"LoggingWork"].clock_in()<CR>' % VIM_PY_CALL,
key_mapping=u'<localleader>ci',
menu_desrc=u'Clock in'
)
add_cmd_mapping_menu(
self,
name=u'OrgLoggingClockOut',
function=u'%s ORGMODE.plugins[u"LoggingWork"].clock_out()<CR>' % VIM_PY_CALL,
key_mapping=u'<localleader>co',
menu_desrc=u'Clock out'
)
add_cmd_mapping_menu(
self,
name=u'OrgLoggingClockUpdate',
function=u'%s ORGMODE.plugins[u"LoggingWork"].clock_update()<CR>' % VIM_PY_CALL,
key_mapping=u'<localleader>cu',
menu_desrc=u'Clock update'
)
add_cmd_mapping_menu(
self,
name=u'OrgLoggingClockTotal',
function=u'%s ORGMODE.plugins[u"LoggingWork"].clock_total()<CR>' % VIM_PY_CALL,
key_mapping=u'<localleader>ct',
menu_desrc=u'Show total clocked time for the current heading'
)
7 changes: 7 additions & 0 deletions ftplugin/orgmode/vimbuffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,13 @@ def write_checkbox(self, checkbox, including_children=True):
def write_checkboxes(self, checkboxes):
pass

def write_clockline(self, clockline):
if clockline._orig_start is None:
raise ValueError('Clock line must contain the attribute _orig_start! %s ' % clockline)

if clockline.is_dirty:
self._content[clockline._orig_start:clockline._orig_start + 1] = [unicode(clockline)]

def previous_heading(self, position=None):
u""" Find the next heading (search forward) and return the related object
:returns: Heading object or None
Expand Down