Outlook COM (`outlook_handlers.py`, agent):
- 4 handlers mirroring Rust pack: unread_count, latest, send_clipboard,
summarize_inbox. Uses `win32com.client` with lazy import so the module
loads even when pywin32 is missing — handlers degrade gracefully and
speak a "Outlook недоступен" reply.
- `requirements.txt`: pywin32>=305 (optional).
- 7 unit tests mock `_outlook_session` via unittest.mock.
Time tracker (`time_tracker_handlers.py`, agent):
- 5 handlers: start, stop, today, week, reset. JSON store at
`<here>/time_tracker.json`, atomic write-then-rename.
- Same shape as the Rust pack's `jarvis.state` blob: current_session_start
+ sessions[{start,end}]. Local-calendar today boundary.
- Full Russian pluralisation (час/часа/часов, минута/минуты/минут).
- 14 unit tests using the existing `isolated_state` fixture in conftest.py.
`extensions.py` + `main.py`: imports + 9 new proxy do_* functions + 9
new dispatch elifs. `commands.yaml`: 9 new entries.
Tests: 60 → 81 (+14 tracker + 7 outlook).
263 lines
8.4 KiB
Python
263 lines
8.4 KiB
Python
"""Time tracker — voice-driven daily work-time log.
|
||
|
||
Parity with the rust `time_tracker` command pack. Stores active session state
|
||
and a list of completed sessions in `<script_dir>/time_tracker.json` with
|
||
atomic write-through (write-then-rename).
|
||
|
||
Public API:
|
||
do_tracker_start(action, voice) -> None
|
||
do_tracker_stop(action, voice) -> None
|
||
do_tracker_today(action, voice) -> None
|
||
do_tracker_week(action, voice) -> None
|
||
do_tracker_reset(action, voice) -> None
|
||
|
||
Lower-level helpers (also used by tests):
|
||
load() -> dict
|
||
save(data) -> None
|
||
start_session(now=None) -> dict # returns updated state
|
||
stop_session(now=None) -> tuple[dict, int|None] # (state, elapsed seconds)
|
||
today_total(now=None) -> int # total seconds today (incl. open session)
|
||
week_total(now=None) -> int # total seconds across last 7 days
|
||
reset_today(now=None) -> tuple[int, bool] # (dropped_count, had_open)
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import time as _time
|
||
|
||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||
_PATH = os.path.join(_HERE, 'time_tracker.json')
|
||
|
||
_speak_fn = print
|
||
|
||
|
||
def set_speak(fn):
|
||
global _speak_fn
|
||
_speak_fn = fn
|
||
|
||
|
||
def _speak(text):
|
||
try:
|
||
_speak_fn(text)
|
||
except Exception as exc:
|
||
print(f"[tracker] speak: {exc}")
|
||
|
||
|
||
# ── persistence ──────────────────────────────────────────────────────────
|
||
|
||
def load() -> dict:
|
||
if not os.path.isfile(_PATH):
|
||
return {'current_session_start': None, 'sessions': []}
|
||
try:
|
||
with open(_PATH, encoding='utf-8') as f:
|
||
data = json.load(f)
|
||
except (OSError, json.JSONDecodeError) as exc:
|
||
print(f"[tracker] corrupt store ({exc}) — starting empty")
|
||
return {'current_session_start': None, 'sessions': []}
|
||
if not isinstance(data, dict):
|
||
return {'current_session_start': None, 'sessions': []}
|
||
data.setdefault('current_session_start', None)
|
||
data.setdefault('sessions', [])
|
||
return data
|
||
|
||
|
||
def save(data: dict) -> None:
|
||
tmp = _PATH + '.tmp'
|
||
try:
|
||
with open(tmp, 'w', encoding='utf-8') as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||
os.replace(tmp, _PATH)
|
||
except OSError as exc:
|
||
print(f"[tracker] save failed: {exc}")
|
||
|
||
|
||
# ── local-day math ───────────────────────────────────────────────────────
|
||
|
||
def _local_today_start(now: int) -> int:
|
||
"""Return the unix timestamp of local midnight on the day that contains
|
||
`now`."""
|
||
tm = _time.localtime(now)
|
||
return now - (tm.tm_hour * 3600 + tm.tm_min * 60 + tm.tm_sec)
|
||
|
||
|
||
# ── operations ───────────────────────────────────────────────────────────
|
||
|
||
def start_session(now: int | None = None) -> dict:
|
||
"""Open a new active session if none is running. Returns the new state."""
|
||
if now is None:
|
||
now = int(_time.time())
|
||
data = load()
|
||
if data.get('current_session_start') is None:
|
||
data['current_session_start'] = now
|
||
save(data)
|
||
return data
|
||
|
||
|
||
def stop_session(now: int | None = None) -> tuple[dict, int | None]:
|
||
"""Close the open session (if any). Returns (state, elapsed_seconds)."""
|
||
if now is None:
|
||
now = int(_time.time())
|
||
data = load()
|
||
start_ts = data.get('current_session_start')
|
||
if start_ts is None:
|
||
return data, None
|
||
elapsed = max(1, now - int(start_ts))
|
||
sessions = data.get('sessions') or []
|
||
sessions.append({'start': int(start_ts), 'end': now})
|
||
data['sessions'] = sessions
|
||
data['current_session_start'] = None
|
||
save(data)
|
||
return data, elapsed
|
||
|
||
|
||
def _sum_overlap(sessions: list[dict], open_start: int | None,
|
||
lo: int, hi: int) -> int:
|
||
total = 0
|
||
for sess in sessions:
|
||
s = int(sess.get('start') or 0)
|
||
e = int(sess.get('end') or 0)
|
||
a = max(s, lo)
|
||
b = min(e, hi)
|
||
if b > a:
|
||
total += b - a
|
||
if open_start is not None:
|
||
a = max(int(open_start), lo)
|
||
if hi > a:
|
||
total += hi - a
|
||
return total
|
||
|
||
|
||
def today_total(now: int | None = None) -> int:
|
||
if now is None:
|
||
now = int(_time.time())
|
||
data = load()
|
||
today_start = _local_today_start(now)
|
||
return _sum_overlap(data.get('sessions') or [],
|
||
data.get('current_session_start'),
|
||
today_start, now)
|
||
|
||
|
||
def week_total(now: int | None = None) -> int:
|
||
if now is None:
|
||
now = int(_time.time())
|
||
data = load()
|
||
today_start = _local_today_start(now)
|
||
week_start = today_start - 6 * 86400
|
||
return _sum_overlap(data.get('sessions') or [],
|
||
data.get('current_session_start'),
|
||
week_start, now)
|
||
|
||
|
||
def reset_today(now: int | None = None) -> tuple[int, bool]:
|
||
"""Drop today's completed sessions and stop any open one.
|
||
Returns (dropped_count, had_open_session)."""
|
||
if now is None:
|
||
now = int(_time.time())
|
||
data = load()
|
||
today_start = _local_today_start(now)
|
||
sessions = data.get('sessions') or []
|
||
kept = [s for s in sessions if int(s.get('start') or 0) < today_start]
|
||
dropped = len(sessions) - len(kept)
|
||
had_open = data.get('current_session_start') is not None
|
||
data['sessions'] = kept
|
||
data['current_session_start'] = None
|
||
save(data)
|
||
return dropped, had_open
|
||
|
||
|
||
# ── formatting ───────────────────────────────────────────────────────────
|
||
|
||
def _ru_hour_word(n: int) -> str:
|
||
n100 = n % 100
|
||
if 11 <= n100 <= 14:
|
||
return 'часов'
|
||
n10 = n % 10
|
||
if n10 == 1:
|
||
return 'час'
|
||
if 2 <= n10 <= 4:
|
||
return 'часа'
|
||
return 'часов'
|
||
|
||
|
||
def _ru_minute_word(n: int) -> str:
|
||
n100 = n % 100
|
||
if 11 <= n100 <= 14:
|
||
return 'минут'
|
||
n10 = n % 10
|
||
if n10 == 1:
|
||
return 'минута'
|
||
if 2 <= n10 <= 4:
|
||
return 'минуты'
|
||
return 'минут'
|
||
|
||
|
||
def _ru_session_word(n: int) -> str:
|
||
n100 = n % 100
|
||
if 11 <= n100 <= 14:
|
||
return 'сессий'
|
||
n10 = n % 10
|
||
if n10 == 1:
|
||
return 'сессия'
|
||
if 2 <= n10 <= 4:
|
||
return 'сессии'
|
||
return 'сессий'
|
||
|
||
|
||
def format_duration_ru(seconds: int) -> str:
|
||
seconds = max(0, int(seconds))
|
||
h = seconds // 3600
|
||
m = (seconds % 3600) // 60
|
||
if h > 0 and m > 0:
|
||
return f"{h} {_ru_hour_word(h)} {m} {_ru_minute_word(m)}"
|
||
if h > 0:
|
||
return f"{h} {_ru_hour_word(h)}"
|
||
return f"{m} {_ru_minute_word(m)}"
|
||
|
||
|
||
# ── voice entry points ───────────────────────────────────────────────────
|
||
|
||
def do_tracker_start(action, voice):
|
||
data = load()
|
||
if data.get('current_session_start') is not None:
|
||
_speak("Уже считаю, сэр.")
|
||
return
|
||
start_session()
|
||
_speak("Отсчёт пошёл.")
|
||
|
||
|
||
def do_tracker_stop(action, voice):
|
||
_, elapsed = stop_session()
|
||
if elapsed is None:
|
||
_speak("Трекер не запущен, сэр.")
|
||
return
|
||
_speak(f"Записал. Сессия: {format_duration_ru(elapsed)}.")
|
||
|
||
|
||
def do_tracker_today(action, voice):
|
||
total = today_total()
|
||
if total < 60:
|
||
_speak("Сегодня ещё ничего не отработано, сэр.")
|
||
return
|
||
_speak(f"Сегодня отработано {format_duration_ru(total)}.")
|
||
|
||
|
||
def do_tracker_week(action, voice):
|
||
total = week_total()
|
||
if total < 60:
|
||
_speak("За эту неделю ничего не отработано, сэр.")
|
||
return
|
||
_speak(f"За неделю отработано {format_duration_ru(total)}.")
|
||
|
||
|
||
def do_tracker_reset(action, voice):
|
||
dropped, had_open = reset_today()
|
||
if dropped == 0 and not had_open:
|
||
_speak("Сегодня сбрасывать нечего, сэр.")
|
||
return
|
||
parts = ["Подтверждаю: трекер за сегодня очищен"]
|
||
if dropped > 0:
|
||
parts.append(f", удалено {dropped} {_ru_session_word(dropped)}")
|
||
if had_open:
|
||
parts.append(", активный отсчёт остановлен")
|
||
parts.append(".")
|
||
_speak("".join(parts))
|