Skip to content

Commit

Permalink
Initial public release
Browse files Browse the repository at this point in the history
  • Loading branch information
Martin Bauer committed Jan 3, 2021
0 parents commit 36b728d
Show file tree
Hide file tree
Showing 13 changed files with 784 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*.pyc
dist
rata_pkg_belugame.egg-info/
19 changes: 19 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Copyright (c) 2021 The Python Packaging Authority

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
79 changes: 79 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# rata: raw time tracker

Terminal based time tracker with git versioning running on python 3 and urwid.

![alt text](https://user-images.githubusercontent.com/16137830/103478188-1c158300-4dc5-11eb-9c61-d2ad23981745.png)

It suits people who only want to track the name, duration, start and end time of tasks they worked on, e.g. for billing
a project per hour. A simple to use replacement for emacs org-mode respectively vim-dotoo.

It's my first urwid project and there is no test coverage. Use at own risk, though it commits all changes directly to
git. So you should always be able to recover your entries.

## Features

- runs in the terminal and adjusts to terminal size
- track time per task in a single text-based file
- quickly change between, add and rename tasks
- shows when you started tracking, since when you are tracking and total duration tracked per task
- edit previous records with check for overlaps
- keep track of all changes through automatic git commits
- sort by duration/name/most recently worked on
- output file loosely based on org-mode, fully text-based. Though please do not try to add something other than time
records to the file. Rata won't be able to read it.

## Installation

pip install rata

## Usage

rata requires a file name in a git-versioned folder. It will create a new commit for any change.

````
git init ~/timerecords
rata ~/timerecords/projectFoo.txt
````

### Key-bindings

#### Main view

- Enter: Start/Stop tracking task under cursor
- up/down: Move cursor over task list
- right: Show and edit list of time records of task under cursor
- n: Add new task and start tracking it
- r: Rename task under cursor
- s: Start/Stop current/last track (independant of cursor position)
- o: Toggle sorting: by name, total task duration, most recently tracked
- q: Quit program

#### Edit mode

- Enter: Edit record under cursor (modify the timestamps and confirm with Enter again)
- h: Quick-edit: Move start time under cursor 1 minute ahead
- j: Quick-edit: Move start time under cursor 1 minute back
- k: Quick-edit: Move end time under cursor 1 minute ahead
- l: Quick-edit: Move end time under cursor 1 minute back
- Esc: Go back to main view

### New task mode

- Enter a name for your new task
- Enter: Confirm
- Esc: Go back to main view

## Sample output file

````
* Client support
:LOGBOOK:
CLOCK: [2020-12-27 10:22:10] -- [2020-12-27 11:30:11]
CLOCK: [2020-12-24 09:30:03] -- [2020-12-24 10:40:06]
:END:
* On-site meetings
:LOGBOOK:
CLOCK: [2020-12-20 13:44:11] -- [2020-12-20 16:50:14]
CLOCK: [2020-12-25 15:00:07] -- [2020-12-25 17:38:10]
````
Empty file added rata/__init__.py
Empty file.
22 changes: 22 additions & 0 deletions rata/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import sys

from .libs.application import Rata
from .libs.io import is_inside_git_dir


def main():
try:
file_name = sys.argv[1]
except IndexError:
print("File name argument missing. Start rata with a path like 'rata timerecords.txt'")
sys.exit()

if not is_inside_git_dir(file_name):
print("No git directory. rata expects the given file name to be git-versioned to commit changes.")
sys.exit()

Rata(file_name)


if __name__ == '__main__':
sys.exit(main())
Empty file added rata/libs/__init__.py
Empty file.
185 changes: 185 additions & 0 deletions rata/libs/application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
from itertools import cycle

import urwid

from .edit_tasks import make_edit_task_screen
from .io import format_duration
from .tasks import TaskManager
from .widgets import NewTask, TaskButtonWrap, RenameTask


order_modes = cycle(["duration", "most recent", "name"])
palette = [
('cyan-bg', 'black', 'light cyan'),
('cyan', 'light cyan', ''),
('normal', '', ''),
('red', 'dark red', ''),
]
refresh_interval = 1
help_line = " | ".join(["Enter: start/stop task", "Right: edit", "(n)ew task", "(s)tart/stop last task", "s(o)rt tasks",
"(r)ename task", "(q)uit"])


class Rata(object):
sort_order = "name"
message_box = urwid.AttrMap(urwid.Text(""), "error")
view = "main"

def __init__(self, file_name):
self.file_name = file_name
self.taskmanager = TaskManager(file_name)
self.current_task = self.taskmanager.find_running_task()
self.body = self.create_tasks_menu()
self.loop = urwid.MainLoop(
self.body,
palette,
unhandled_input=self.handle_input
)
self.loop.set_alarm_in(refresh_interval, self.refresh)
self.loop.run()

def refresh(self, loop, data):
"""Update clocks on the screen: current task duration & total task duration."""
if self.view == "main": # Don't do anything if not on main screen as widgets are different
if self.current_task:
self.current_task.button.base_widget.set_label(self.current_task.label)
self.set_message("{}: {}".format(self.current_task.name,
self.current_task.running_record.label_with_duration))
else:
self.set_message("")
if hasattr(self.body.base_widget, "body"):
self.body.base_widget.body[0].set_text(self.title)
loop.set_alarm_in(refresh_interval, self.refresh)

def to_main_screen(self):
"""Replace what is on the screen and show the main screen again"""
focus = None
if self.view == "main":
focus = self.body.original_widget.base_widget.get_focus()[0].base_widget
self.view = "main"
self.set_message("")
self.body.original_widget = self.create_tasks_menu()
# Move cursor to where it was before:
if focus:
for index, w in enumerate(self.body.original_widget.base_widget.body):
if hasattr(w.base_widget, "button") and w.base_widget.task == focus.task:
self.body.original_widget.base_widget.set_focus(index)

def set_message(self, message, error=False):
if error:
self.message_box.set_attr_map({None: 'red'})
else:
self.message_box.set_attr_map({None: 'cyan'})
self.message_box.base_widget.set_text(message)

@property
def title(self):
"""Defines top line shown above the task table."""
return "Tasks {} - by {} - Total {} - Today {}".format(
self.file_name, self.sort_order, format_duration(self.taskmanager.total_duration),
format_duration(self.taskmanager.todays_duration))

def create_tasks_menu(self):
"""Renders title and bottom line and in the middle a scrollable table of tasks with their duration"""
body = [urwid.Text(self.title), urwid.Divider()]
for task in self.taskmanager.tasks_by(self.sort_order):
button = TaskButtonWrap(task)
urwid.connect_signal(button.button, "click", self.toggle_recording_task, task)
if task == self.current_task:
button = urwid.AttrMap(button, "cyan")
else:
button = urwid.AttrMap(button, "normal", "cyan-bg")
task.button = button
body.append(button)
body += [urwid.Divider(),
self.message_box,
urwid.Divider(),
urwid.Text(help_line)]
return urwid.Padding(urwid.ListBox(urwid.SimpleFocusListWalker(body)), left=0, right=0)

def toggle_recording_task(self, button, task):
"""Event handler for Enter-key on a task line: start/stop recording"""
if self.current_task:
self.current_task.stop()
if self.current_task == task: # Enter was pressed on currently running task
self.current_task = None
else:
self.current_task = task
self.current_task.start()
self.to_main_screen()

def handle_input(self, key):
"""Main widget key press event handler."""
if self.view == "main": # Don't do anything if not on main screen as widgets are different
if key == 's': # stop tracking
self._start_stop_tracking()
return
if key == 'r': # rename task under cursor
self._rename_task()
return
elif key == 'n': # track new task
self._new_task()
return
elif key == 'o': # sort
self._sort()
return
elif key == 'right': # edit
self._edit_task()
return
if key == 'q': # quit
self._quit()
return
if key == 'esc':
self.to_main_screen()

def _quit(self):
if self.current_task:
self.current_task.stop()
self.current_task = None
raise urwid.ExitMainLoop()

def _start_stop_tracking(self):
if not self.taskmanager.tasks:
return
if self.current_task:
self.current_task.stop()
self.current_task = None
self.to_main_screen() # Will resort
else:
tasks = self.taskmanager.tasks[:]
tasks.sort(key=lambda t: t.most_recent, reverse=True)
self.current_task = tasks[0]
tasks[0].start()

def _rename_task(self):
if not self.taskmanager.tasks:
return
self.view = "rename"
task = self.body.base_widget.focus.base_widget.task
edit = RenameTask(self, task, u"Rename task:\n")
fill = urwid.Filler(edit)
self.body.original_widget = fill

def _new_task(self):
self.view = "new task"
edit = NewTask(self, u"New task:\n")
fill = urwid.Filler(edit)
self.body.original_widget = fill

def _sort(self):
self.sort_order = next(order_modes)
self.to_main_screen() # Will resort

def _edit_task(self):
if not self.taskmanager.tasks:
return
self.body.original_widget = make_edit_task_screen(self)

def add_new_task(self, name):
"""Event handler for when a new task name was entered: Adds and starts tracking it."""
task = self.taskmanager.add(name)
if self.current_task:
self.current_task.stop()
self.current_task = task
self.to_main_screen()
task.start()
28 changes: 28 additions & 0 deletions rata/libs/edit_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import urwid

from .widgets import RecordEdit, RecordButton


def make_edit_task_screen(application):
application.view = "edit"
body = [urwid.Text("Edit records"), urwid.Divider()]
task = application.body.base_widget.focus.base_widget.task
task.records.sort(key=lambda r: r.start, reverse=True)
for r in task.records:
button = RecordButton(r, application)
urwid.connect_signal(button, 'click', _edit_record, (application, r))
body.append(button)
body += [urwid.Divider(),
urwid.Text("Enter: edit | h/j: Move start up/down | k/l: Move end up/down | Esc: cancel"),
urwid.Divider(),
application.message_box]
return urwid.Padding(urwid.ListBox(urwid.SimpleFocusListWalker(body)), left=0, right=0)


def _edit_record(button, record_tuple):
application, record = record_tuple
application.set_message("")
application.body.original_widget = urwid.ListBox([
RecordEdit(application, record, edit_text=record.label),
urwid.Divider(),
application.message_box])
2 changes: 2 additions & 0 deletions rata/libs/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class RecordOverlap(Exception):
pass
Loading

0 comments on commit 36b728d

Please sign in to comment.