klok-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- klok/__init__.py +3 -0
- klok/__main__.py +4 -0
- klok/cli.py +1816 -0
- klok/config.py +162 -0
- klok/focus.py +169 -0
- klok/formats.py +169 -0
- klok/mindful.py +434 -0
- klok/model.py +111 -0
- klok/report.py +308 -0
- klok/storage.py +327 -0
- klok/timeparse.py +417 -0
- klok/utils.py +115 -0
- klok_cli-1.0.0.dist-info/METADATA +466 -0
- klok_cli-1.0.0.dist-info/RECORD +18 -0
- klok_cli-1.0.0.dist-info/WHEEL +5 -0
- klok_cli-1.0.0.dist-info/entry_points.txt +2 -0
- klok_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- klok_cli-1.0.0.dist-info/top_level.txt +1 -0
klok/config.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Configuration and on-disk locations.
|
|
2
|
+
|
|
3
|
+
Everything klok keeps lives under a single data directory so a whole
|
|
4
|
+
install can be backed up, synced or version-controlled by copying one
|
|
5
|
+
folder. ``KLOK_HOME`` overrides it, which is also what the test-suite
|
|
6
|
+
uses to stay away from real data.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import configparser
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
DEFAULTS = {
|
|
14
|
+
"general": {
|
|
15
|
+
# Project used by `klok start` when none is given.
|
|
16
|
+
"default_project": "",
|
|
17
|
+
# monday or sunday - drives :week and the week charts.
|
|
18
|
+
"week_start": "monday",
|
|
19
|
+
# Round durations to a multiple of this many minutes in reports (0 = off).
|
|
20
|
+
"round": "0",
|
|
21
|
+
# 24 or 12 hour clock in rendered output.
|
|
22
|
+
"time_format": "24",
|
|
23
|
+
# Stop a running frame automatically when a new one is started.
|
|
24
|
+
"autostop": "true",
|
|
25
|
+
# auto, always or never.
|
|
26
|
+
"color": "auto",
|
|
27
|
+
# Overrides $VISUAL / $EDITOR for `klok edit`.
|
|
28
|
+
"editor": "",
|
|
29
|
+
# Refuse to create a project that has never been seen before.
|
|
30
|
+
"confirm_new_project": "false",
|
|
31
|
+
},
|
|
32
|
+
"exclusions": {
|
|
33
|
+
# Hours considered "working hours"; `klok gaps` ignores anything outside.
|
|
34
|
+
"hours": "",
|
|
35
|
+
# Days that are never expected to hold tracked time.
|
|
36
|
+
"days": "",
|
|
37
|
+
# Comma separated YYYY-MM-DD dates treated like excluded days.
|
|
38
|
+
"holidays": "",
|
|
39
|
+
},
|
|
40
|
+
"mindful": {
|
|
41
|
+
# Practice is recorded here so it never lands in a client's report.
|
|
42
|
+
"project": "mindfulness",
|
|
43
|
+
"sheet": "wellbeing",
|
|
44
|
+
"track": "true",
|
|
45
|
+
# Default breathing pattern and length.
|
|
46
|
+
"pattern": "box",
|
|
47
|
+
"breath_rounds": "6",
|
|
48
|
+
# Default sit, and how often the interval bell rings during it.
|
|
49
|
+
"default_sit": "10m",
|
|
50
|
+
"interval_bell": "",
|
|
51
|
+
"warmup": "",
|
|
52
|
+
"guidance": "true",
|
|
53
|
+
# `klok status` suggests a pause once a stretch of work runs this long.
|
|
54
|
+
"break_after": "90m",
|
|
55
|
+
},
|
|
56
|
+
"focus": {
|
|
57
|
+
"work": "25m",
|
|
58
|
+
"break": "5m",
|
|
59
|
+
"long_break": "15m",
|
|
60
|
+
"rounds": "4",
|
|
61
|
+
"notify": "true",
|
|
62
|
+
"bell": "true",
|
|
63
|
+
# Record focus sessions as tracked frames.
|
|
64
|
+
"track": "true",
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
TRUTHY = {"1", "true", "yes", "on", "y"}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def data_home() -> Path:
|
|
72
|
+
"""Directory holding frames, state, config and the undo stack."""
|
|
73
|
+
env = os.environ.get("KLOK_HOME")
|
|
74
|
+
if env:
|
|
75
|
+
return Path(env).expanduser()
|
|
76
|
+
xdg = os.environ.get("XDG_DATA_HOME")
|
|
77
|
+
base = Path(xdg).expanduser() if xdg else Path.home() / ".local" / "share"
|
|
78
|
+
return base / "klok"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Config:
|
|
82
|
+
def __init__(self, home: Path = None):
|
|
83
|
+
self.home = Path(home) if home else data_home()
|
|
84
|
+
self.path = self.home / "config.ini"
|
|
85
|
+
self._parser = configparser.ConfigParser()
|
|
86
|
+
self._parser.read_dict(DEFAULTS)
|
|
87
|
+
if self.path.exists():
|
|
88
|
+
self._parser.read(self.path, encoding="utf-8")
|
|
89
|
+
|
|
90
|
+
# -- paths ---------------------------------------------------------
|
|
91
|
+
@property
|
|
92
|
+
def frames_path(self) -> Path:
|
|
93
|
+
return self.home / "frames.jsonl"
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def state_path(self) -> Path:
|
|
97
|
+
return self.home / "state.json"
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def undo_path(self) -> Path:
|
|
101
|
+
return self.home / "undo.jsonl"
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def checkins_path(self) -> Path:
|
|
105
|
+
return self.home / "checkins.jsonl"
|
|
106
|
+
|
|
107
|
+
def ensure_home(self) -> None:
|
|
108
|
+
self.home.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
|
|
110
|
+
# -- values --------------------------------------------------------
|
|
111
|
+
def get(self, key: str, fallback=None):
|
|
112
|
+
section, _, option = key.partition(".")
|
|
113
|
+
if not option:
|
|
114
|
+
raise KeyError("config keys look like 'section.option', got %r" % key)
|
|
115
|
+
try:
|
|
116
|
+
return self._parser.get(section, option)
|
|
117
|
+
except (configparser.NoSectionError, configparser.NoOptionError):
|
|
118
|
+
return fallback
|
|
119
|
+
|
|
120
|
+
def get_bool(self, key: str, fallback: bool = False) -> bool:
|
|
121
|
+
raw = self.get(key)
|
|
122
|
+
if raw is None or raw == "":
|
|
123
|
+
return fallback
|
|
124
|
+
return str(raw).strip().lower() in TRUTHY
|
|
125
|
+
|
|
126
|
+
def get_int(self, key: str, fallback: int = 0) -> int:
|
|
127
|
+
raw = self.get(key)
|
|
128
|
+
try:
|
|
129
|
+
return int(str(raw).strip())
|
|
130
|
+
except (TypeError, ValueError):
|
|
131
|
+
return fallback
|
|
132
|
+
|
|
133
|
+
def get_list(self, key: str):
|
|
134
|
+
raw = self.get(key) or ""
|
|
135
|
+
return [part.strip() for part in raw.split(",") if part.strip()]
|
|
136
|
+
|
|
137
|
+
def set(self, key: str, value: str) -> None:
|
|
138
|
+
section, _, option = key.partition(".")
|
|
139
|
+
if not option:
|
|
140
|
+
raise KeyError("config keys look like 'section.option', got %r" % key)
|
|
141
|
+
if not self._parser.has_section(section):
|
|
142
|
+
self._parser.add_section(section)
|
|
143
|
+
self._parser.set(section, option, value)
|
|
144
|
+
|
|
145
|
+
def unset(self, key: str) -> bool:
|
|
146
|
+
section, _, option = key.partition(".")
|
|
147
|
+
try:
|
|
148
|
+
return self._parser.remove_option(section, option)
|
|
149
|
+
except configparser.NoSectionError:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
def items(self):
|
|
153
|
+
for section in sorted(self._parser.sections()):
|
|
154
|
+
for option, value in sorted(self._parser.items(section)):
|
|
155
|
+
yield "%s.%s" % (section, option), value
|
|
156
|
+
|
|
157
|
+
def save(self) -> None:
|
|
158
|
+
self.ensure_home()
|
|
159
|
+
tmp = self.path.with_suffix(".ini.tmp")
|
|
160
|
+
with tmp.open("w", encoding="utf-8") as handle:
|
|
161
|
+
self._parser.write(handle)
|
|
162
|
+
os.replace(tmp, self.path)
|
klok/focus.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Countdown timers, pomodoro rounds and desktop notifications.
|
|
2
|
+
|
|
3
|
+
This is the part that covers the "minimal timer" tools - a visible
|
|
4
|
+
countdown, a big clock, a nudge when the time is up - and it feeds the
|
|
5
|
+
same frame store as everything else so focus sessions show up in reports.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from datetime import timedelta
|
|
13
|
+
|
|
14
|
+
from klok.timeparse import format_duration
|
|
15
|
+
from klok.utils import bar
|
|
16
|
+
|
|
17
|
+
BIG_DIGITS = {
|
|
18
|
+
"0": ["█▀▀█", "█ █", "█ █", "█ █", "█▄▄█"],
|
|
19
|
+
"1": [" ▀█", " █", " █", " █", " █"],
|
|
20
|
+
"2": ["█▀▀█", " █", " ▄▀ ", "▄▀ ", "█▄▄▄"],
|
|
21
|
+
"3": ["█▀▀█", " █", " ▀▀▄", " █", "█▄▄█"],
|
|
22
|
+
"4": ["█ █", "█ █", "█▄▄█", " █", " █"],
|
|
23
|
+
"5": ["█▀▀▀", "█▄▄ ", " █", " █", "▀▄▄▀"],
|
|
24
|
+
"6": ["█▀▀▀", "█ ", "█▀▀█", "█ █", "█▄▄█"],
|
|
25
|
+
"7": ["▀▀▀█", " █", " ▄▀", " ▄▀ ", "▄▀ "],
|
|
26
|
+
"8": ["█▀▀█", "█ █", "█▄▄█", "█ █", "█▄▄█"],
|
|
27
|
+
"9": ["█▀▀█", "█ █", "▀▀▀█", " █", "▄▄▄█"],
|
|
28
|
+
":": [" ", " ██ ", " ", " ██ ", " "],
|
|
29
|
+
" ": [" ", " ", " ", " ", " "],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
ART = {
|
|
33
|
+
"coffee": [
|
|
34
|
+
" ( ) ( ) )",
|
|
35
|
+
" ) ( ) ( (",
|
|
36
|
+
" ( ) ( ) )",
|
|
37
|
+
" _____________",
|
|
38
|
+
" <_____________> ___",
|
|
39
|
+
" | |/ _ \\",
|
|
40
|
+
" | | | |",
|
|
41
|
+
" | |_| |",
|
|
42
|
+
" ___| |\\___/",
|
|
43
|
+
" / \\___________/ \\",
|
|
44
|
+
" \\_____________________/",
|
|
45
|
+
],
|
|
46
|
+
"tree": [
|
|
47
|
+
" &&& && & &&",
|
|
48
|
+
" && &\\/&\\|& ()|/ @, &&",
|
|
49
|
+
" &\\/(/&/&||/& /_/)_&/_&",
|
|
50
|
+
" &() &\\/&|()|/&\\/ '%\" & ()",
|
|
51
|
+
" &_\\_&&_\\ |& |&&/&__%_/_& &&",
|
|
52
|
+
"&& && & &| &| /& & % ()& /&&",
|
|
53
|
+
" ||| |||",
|
|
54
|
+
],
|
|
55
|
+
"cat": [
|
|
56
|
+
" /\\_/\\",
|
|
57
|
+
" ( o.o )",
|
|
58
|
+
" > ^ <",
|
|
59
|
+
],
|
|
60
|
+
"none": [],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def notify(title: str, message: str, enabled: bool = True, bell: bool = True) -> None:
|
|
65
|
+
"""Best-effort desktop notification; never fails the command."""
|
|
66
|
+
if bell:
|
|
67
|
+
try:
|
|
68
|
+
sys.stdout.write("\a")
|
|
69
|
+
sys.stdout.flush()
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
if not enabled:
|
|
73
|
+
return
|
|
74
|
+
commands = []
|
|
75
|
+
if shutil.which("notify-send"):
|
|
76
|
+
commands.append(["notify-send", title, message])
|
|
77
|
+
if shutil.which("terminal-notifier"):
|
|
78
|
+
commands.append(["terminal-notifier", "-title", title, "-message", message])
|
|
79
|
+
if sys.platform == "darwin" and shutil.which("osascript"):
|
|
80
|
+
script = 'display notification "%s" with title "%s"' % (
|
|
81
|
+
message.replace('"', "'"), title.replace('"', "'"))
|
|
82
|
+
commands.append(["osascript", "-e", script])
|
|
83
|
+
if sys.platform.startswith("win") and shutil.which("powershell"):
|
|
84
|
+
commands.append(["powershell", "-NoProfile", "-Command",
|
|
85
|
+
"[console]::beep(880,300); Write-Host '%s: %s'" % (title, message)])
|
|
86
|
+
for command in commands:
|
|
87
|
+
try:
|
|
88
|
+
subprocess.run(command, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
89
|
+
return
|
|
90
|
+
except OSError:
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def big_clock(text: str) -> str:
|
|
95
|
+
"""Render ``MM:SS`` in block characters, arttime style."""
|
|
96
|
+
rows = ["" for _ in range(5)]
|
|
97
|
+
for char in text:
|
|
98
|
+
glyph = BIG_DIGITS.get(char, BIG_DIGITS[" "])
|
|
99
|
+
for index in range(5):
|
|
100
|
+
rows[index] += glyph[index] + " "
|
|
101
|
+
return "\n".join(row.rstrip() for row in rows)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _clock_text(remaining: timedelta) -> str:
|
|
105
|
+
total = max(0, int(remaining.total_seconds()))
|
|
106
|
+
hours, rest = divmod(total, 3600)
|
|
107
|
+
minutes, seconds = divmod(rest, 60)
|
|
108
|
+
if hours:
|
|
109
|
+
return "%d:%02d:%02d" % (hours, minutes, seconds)
|
|
110
|
+
return "%02d:%02d" % (minutes, seconds)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def countdown(duration: timedelta, label: str, term, art: str = None,
|
|
114
|
+
big: bool = False, quiet: bool = False, notify_on_end: bool = True,
|
|
115
|
+
bell: bool = True, stream=None) -> bool:
|
|
116
|
+
"""Run a countdown. Returns False when the user interrupts it."""
|
|
117
|
+
stream = stream or sys.stdout
|
|
118
|
+
total_seconds = max(1, int(duration.total_seconds()))
|
|
119
|
+
started = time.monotonic()
|
|
120
|
+
art_lines = ART.get(art or "", []) if art else []
|
|
121
|
+
interactive = bool(getattr(stream, "isatty", lambda: False)()) and not quiet
|
|
122
|
+
drawn_lines = 0
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
while True:
|
|
126
|
+
elapsed = time.monotonic() - started
|
|
127
|
+
remaining = timedelta(seconds=max(0, total_seconds - int(elapsed)))
|
|
128
|
+
if interactive:
|
|
129
|
+
block = []
|
|
130
|
+
if art_lines:
|
|
131
|
+
block.extend(art_lines)
|
|
132
|
+
block.append("")
|
|
133
|
+
if big:
|
|
134
|
+
block.extend(big_clock(_clock_text(remaining)).splitlines())
|
|
135
|
+
block.append("")
|
|
136
|
+
width = min(40, max(20, term.width() - 30))
|
|
137
|
+
block.append("%s %s %s" % (
|
|
138
|
+
term.paint(label, "bold"),
|
|
139
|
+
term.paint(bar(total_seconds - remaining.total_seconds(), total_seconds, width), "cyan"),
|
|
140
|
+
term.paint(_clock_text(remaining), "yellow")))
|
|
141
|
+
if drawn_lines:
|
|
142
|
+
stream.write("\033[%dA" % drawn_lines)
|
|
143
|
+
for line in block:
|
|
144
|
+
stream.write("\033[2K" + line + "\n")
|
|
145
|
+
drawn_lines = len(block)
|
|
146
|
+
stream.flush()
|
|
147
|
+
if remaining.total_seconds() <= 0:
|
|
148
|
+
break
|
|
149
|
+
time.sleep(min(1.0, max(0.05, total_seconds - elapsed)))
|
|
150
|
+
except KeyboardInterrupt:
|
|
151
|
+
if interactive:
|
|
152
|
+
stream.write("\n")
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
if not interactive and not quiet:
|
|
156
|
+
stream.write("%s finished (%s)\n" % (label, format_duration(duration)))
|
|
157
|
+
notify("klok", "%s finished (%s)" % (label, format_duration(duration)),
|
|
158
|
+
enabled=notify_on_end, bell=bell)
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def pomodoro_plan(rounds: int, work: timedelta, short_break: timedelta, long_break: timedelta):
|
|
163
|
+
"""Yield ``(kind, label, duration)`` for a full pomodoro cycle."""
|
|
164
|
+
for index in range(1, rounds + 1):
|
|
165
|
+
yield ("work", "Focus %d/%d" % (index, rounds), work)
|
|
166
|
+
if index == rounds:
|
|
167
|
+
yield ("long_break", "Long break", long_break)
|
|
168
|
+
else:
|
|
169
|
+
yield ("break", "Break %d" % index, short_break)
|
klok/formats.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Machine-readable output: JSON, CSV, iCalendar, ledger timeclock, Markdown.
|
|
2
|
+
|
|
3
|
+
These are what let klok hand its data to a spreadsheet, an invoicing
|
|
4
|
+
script or ``hledger`` without anyone writing a parser.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import csv
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
from datetime import timedelta
|
|
11
|
+
|
|
12
|
+
from klok.model import Frame
|
|
13
|
+
from klok.timeparse import format_duration, to_iso
|
|
14
|
+
|
|
15
|
+
FORMATS = ("text", "json", "csv", "ical", "ledger", "timeclock", "md", "tsv")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _rows(frames, now):
|
|
19
|
+
for frame in frames:
|
|
20
|
+
end = frame.end_or(now)
|
|
21
|
+
yield {
|
|
22
|
+
"id": frame.id,
|
|
23
|
+
"sheet": frame.sheet,
|
|
24
|
+
"project": frame.project,
|
|
25
|
+
"tags": frame.tags,
|
|
26
|
+
"note": frame.note,
|
|
27
|
+
"start": to_iso(frame.start),
|
|
28
|
+
"stop": to_iso(frame.stop) if frame.stop else "",
|
|
29
|
+
"running": frame.running,
|
|
30
|
+
"seconds": int((end - frame.start).total_seconds()),
|
|
31
|
+
"hours": round((end - frame.start).total_seconds() / 3600.0, 4),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def to_json(frames, now, indent: int = 2) -> str:
|
|
36
|
+
return json.dumps(list(_rows(frames, now)), indent=indent, ensure_ascii=False)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def to_csv(frames, now, delimiter: str = ",") -> str:
|
|
40
|
+
buffer = io.StringIO()
|
|
41
|
+
fields = ["id", "sheet", "project", "tags", "note", "start", "stop", "seconds", "hours"]
|
|
42
|
+
writer = csv.DictWriter(buffer, fieldnames=fields, delimiter=delimiter, lineterminator="\n")
|
|
43
|
+
writer.writeheader()
|
|
44
|
+
for row in _rows(frames, now):
|
|
45
|
+
row = dict(row)
|
|
46
|
+
row["tags"] = " ".join(row["tags"])
|
|
47
|
+
row.pop("running", None)
|
|
48
|
+
writer.writerow(row)
|
|
49
|
+
return buffer.getvalue().rstrip("\n")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def to_ical(frames, now) -> str:
|
|
53
|
+
def stamp(value):
|
|
54
|
+
return value.astimezone().strftime("%Y%m%dT%H%M%S")
|
|
55
|
+
|
|
56
|
+
lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//klok//EN", "CALSCALE:GREGORIAN"]
|
|
57
|
+
for frame in frames:
|
|
58
|
+
summary = frame.project or "(no project)"
|
|
59
|
+
if frame.tags:
|
|
60
|
+
summary += " " + " ".join("+" + tag for tag in frame.tags)
|
|
61
|
+
lines += [
|
|
62
|
+
"BEGIN:VEVENT",
|
|
63
|
+
"UID:%s@klok" % frame.id,
|
|
64
|
+
"DTSTART:%s" % stamp(frame.start),
|
|
65
|
+
"DTEND:%s" % stamp(frame.end_or(now)),
|
|
66
|
+
"SUMMARY:%s" % _escape_ical(summary),
|
|
67
|
+
]
|
|
68
|
+
if frame.note:
|
|
69
|
+
lines.append("DESCRIPTION:%s" % _escape_ical(frame.note))
|
|
70
|
+
lines.append("END:VEVENT")
|
|
71
|
+
lines.append("END:VCALENDAR")
|
|
72
|
+
return "\n".join(lines)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _escape_ical(text: str) -> str:
|
|
76
|
+
return text.replace("\\", "\\\\").replace(";", "\;").replace(",", "\\,").replace("\n", "\\n")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def to_timeclock(frames, now) -> str:
|
|
80
|
+
"""The ``i``/``o`` timeclock format ledger and hledger read natively."""
|
|
81
|
+
lines = []
|
|
82
|
+
for frame in frames:
|
|
83
|
+
account = frame.project or "unsorted"
|
|
84
|
+
if frame.tags:
|
|
85
|
+
account += ":" + ":".join(frame.tags)
|
|
86
|
+
lines.append("i %s %s%s" % (frame.start.strftime("%Y/%m/%d %H:%M:%S"), account,
|
|
87
|
+
(" " + frame.note) if frame.note else ""))
|
|
88
|
+
lines.append("o %s" % frame.end_or(now).strftime("%Y/%m/%d %H:%M:%S"))
|
|
89
|
+
return "\n".join(lines)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def to_ledger(frames, now) -> str:
|
|
93
|
+
"""Postings with hour amounts, one transaction per entry."""
|
|
94
|
+
lines = []
|
|
95
|
+
for frame in frames:
|
|
96
|
+
hours = (frame.end_or(now) - frame.start).total_seconds() / 3600.0
|
|
97
|
+
payee = frame.note or frame.project or "time"
|
|
98
|
+
lines.append("%s * %s" % (frame.start.strftime("%Y/%m/%d"), payee))
|
|
99
|
+
account = (frame.project or "unsorted").replace(".", ":")
|
|
100
|
+
lines.append(" %s %.2fh" % (account, hours))
|
|
101
|
+
lines.append(" (%s)" % frame.sheet)
|
|
102
|
+
lines.append("")
|
|
103
|
+
return "\n".join(lines).rstrip()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def to_markdown(frames, now) -> str:
|
|
107
|
+
lines = ["| Date | Start | End | Project | Tags | Note | Duration |",
|
|
108
|
+
"| --- | --- | --- | --- | --- | --- | --- |"]
|
|
109
|
+
total = timedelta(0)
|
|
110
|
+
for frame in frames:
|
|
111
|
+
length = frame.end_or(now) - frame.start
|
|
112
|
+
total += length
|
|
113
|
+
lines.append("| %s | %s | %s | %s | %s | %s | %s |" % (
|
|
114
|
+
frame.start.strftime("%Y-%m-%d"),
|
|
115
|
+
frame.start.strftime("%H:%M"),
|
|
116
|
+
frame.stop.strftime("%H:%M") if frame.stop else "running",
|
|
117
|
+
frame.project or "",
|
|
118
|
+
" ".join("+" + tag for tag in frame.tags),
|
|
119
|
+
frame.note.replace("|", "\\|"),
|
|
120
|
+
format_duration(length),
|
|
121
|
+
))
|
|
122
|
+
lines.append("")
|
|
123
|
+
lines.append("**Total: %s**" % format_duration(total))
|
|
124
|
+
return "\n".join(lines)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def render(frames, now, fmt: str) -> str:
|
|
128
|
+
fmt = (fmt or "json").lower()
|
|
129
|
+
if fmt == "json":
|
|
130
|
+
return to_json(frames, now)
|
|
131
|
+
if fmt == "csv":
|
|
132
|
+
return to_csv(frames, now)
|
|
133
|
+
if fmt == "tsv":
|
|
134
|
+
return to_csv(frames, now, delimiter="\t")
|
|
135
|
+
if fmt == "ical":
|
|
136
|
+
return to_ical(frames, now)
|
|
137
|
+
if fmt == "timeclock":
|
|
138
|
+
return to_timeclock(frames, now)
|
|
139
|
+
if fmt == "ledger":
|
|
140
|
+
return to_ledger(frames, now)
|
|
141
|
+
if fmt in ("md", "markdown"):
|
|
142
|
+
return to_markdown(frames, now)
|
|
143
|
+
raise ValueError("unknown format %r (pick one of %s)" % (fmt, ", ".join(FORMATS)))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_import(text: str):
|
|
147
|
+
"""Read frames from JSON, JSONL or CSV produced by klok or a sibling tool."""
|
|
148
|
+
text = text.strip()
|
|
149
|
+
if not text:
|
|
150
|
+
return []
|
|
151
|
+
if text.startswith("["):
|
|
152
|
+
return [Frame.from_dict(item) for item in json.loads(text)]
|
|
153
|
+
if text.startswith("{"):
|
|
154
|
+
return [Frame.from_dict(json.loads(line)) for line in text.splitlines() if line.strip()]
|
|
155
|
+
reader = csv.DictReader(io.StringIO(text))
|
|
156
|
+
frames = []
|
|
157
|
+
for row in reader:
|
|
158
|
+
if not row.get("start"):
|
|
159
|
+
continue
|
|
160
|
+
frames.append(Frame.from_dict({
|
|
161
|
+
"id": row.get("id"),
|
|
162
|
+
"start": row["start"],
|
|
163
|
+
"stop": row.get("stop") or row.get("end") or None,
|
|
164
|
+
"project": row.get("project", ""),
|
|
165
|
+
"tags": row.get("tags", ""),
|
|
166
|
+
"note": row.get("note", ""),
|
|
167
|
+
"sheet": row.get("sheet") or "default",
|
|
168
|
+
}))
|
|
169
|
+
return frames
|