feat: Wave 5 Python parity — outlook COM + time tracker
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:
parent
45d9425ed7
commit
5265cb0fbe
8 changed files with 971 additions and 0 deletions
263
time_tracker_handlers.py
Normal file
263
time_tracker_handlers.py
Normal 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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue