From 3cc8e15249631536678a549680446305ca5830ba Mon Sep 17 00:00:00 2001 From: Maxim Koltsov Date: Wed, 20 Jun 2018 14:47:08 +0300 Subject: [PATCH] Implement basic clocking support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added four new commands: - Start clocking in the current heading - Stop last unfinished line in the current heading - Recalculate duration after manual change of clock line - Display total time for the current heading --- doc/orgguide.txt | 23 +++- ftplugin/org.vim | 2 +- ftplugin/orgmode/liborgmode/headings.py | 22 +++- ftplugin/orgmode/liborgmode/logbook.py | 54 +++++++++ ftplugin/orgmode/liborgmode/orgdate.py | 29 ++++- ftplugin/orgmode/plugins/Date.py | 2 +- ftplugin/orgmode/plugins/LoggingWork.py | 141 ++++++++++++++++++++++-- ftplugin/orgmode/vimbuffer.py | 7 ++ 8 files changed, 266 insertions(+), 14 deletions(-) create mode 100644 ftplugin/orgmode/liborgmode/logbook.py diff --git a/doc/orgguide.txt b/doc/orgguide.txt index e8fe382d..49b24436 100644 --- a/doc/orgguide.txt +++ b/doc/orgguide.txt @@ -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-ci + 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 + + 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’ + + cu Update duration of the current clock line after the + the timestamps were changed manually + + 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* diff --git a/ftplugin/org.vim b/ftplugin/org.vim index f973ab63..f96af505 100644 --- a/ftplugin/org.vim +++ b/ftplugin/org.vim @@ -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 diff --git a/ftplugin/orgmode/liborgmode/headings.py b/ftplugin/orgmode/liborgmode/headings.py index dcef362d..4ab0a7ae 100644 --- a/ftplugin/orgmode/liborgmode/headings.py +++ b/ftplugin/orgmode/liborgmode/headings.py @@ -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 * @@ -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: @@ -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: + 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 @@ -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""" diff --git a/ftplugin/orgmode/liborgmode/logbook.py b/ftplugin/orgmode/liborgmode/logbook.py new file mode 100644 index 00000000..036db691 --- /dev/null +++ b/ftplugin/orgmode/liborgmode/logbook.py @@ -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 diff --git a/ftplugin/orgmode/liborgmode/orgdate.py b/ftplugin/orgmode/liborgmode/orgdate.py index 93a6776a..35e3c725 100644 --- a/ftplugin/orgmode/liborgmode/orgdate.py +++ b/ftplugin/orgmode/liborgmode/orgdate.py @@ -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>-- @@ -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: @@ -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. @@ -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]" % ( @@ -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: diff --git a/ftplugin/orgmode/plugins/Date.py b/ftplugin/orgmode/plugins/Date.py index 04a675ee..9e0c3c7b 100644 --- a/ftplugin/orgmode/plugins/Date.py +++ b/ftplugin/orgmode/plugins/Date.py @@ -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'))), diff --git a/ftplugin/orgmode/plugins/LoggingWork.py b/ftplugin/orgmode/plugins/LoggingWork.py index 1767984a..eccb26b9 100644 --- a/ftplugin/orgmode/plugins/LoggingWork.py +++ b/ftplugin/orgmode/plugins/LoggingWork.py @@ -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): + 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 """ @@ -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): + 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()' % VIM_PY_CALL, + key_mapping=u'ci', + menu_desrc=u'Clock in' + ) + add_cmd_mapping_menu( + self, + name=u'OrgLoggingClockOut', + function=u'%s ORGMODE.plugins[u"LoggingWork"].clock_out()' % VIM_PY_CALL, + key_mapping=u'co', + menu_desrc=u'Clock out' + ) + add_cmd_mapping_menu( + self, + name=u'OrgLoggingClockUpdate', + function=u'%s ORGMODE.plugins[u"LoggingWork"].clock_update()' % VIM_PY_CALL, + key_mapping=u'cu', + menu_desrc=u'Clock update' + ) + add_cmd_mapping_menu( + self, + name=u'OrgLoggingClockTotal', + function=u'%s ORGMODE.plugins[u"LoggingWork"].clock_total()' % VIM_PY_CALL, + key_mapping=u'ct', + menu_desrc=u'Show total clocked time for the current heading' + ) diff --git a/ftplugin/orgmode/vimbuffer.py b/ftplugin/orgmode/vimbuffer.py index b4760fb8..cb440154 100644 --- a/ftplugin/orgmode/vimbuffer.py +++ b/ftplugin/orgmode/vimbuffer.py @@ -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