feat: Wave 5 Python parity — outlook COM + time tracker
Some checks are pending
Python CI / pytest (ubuntu-latest) (push) Waiting to run
Python CI / pytest (windows-latest) (push) Waiting to run
Python CI / ruff (push) Waiting to run

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).
This commit is contained in:
Bossiara13 2026-05-16 14:34:44 +03:00
parent 45d9425ed7
commit 5265cb0fbe
8 changed files with 971 additions and 0 deletions

263
time_tracker_handlers.py Normal file
View file

@ -0,0 +1,263 @@
"""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))