diff --git a/commands.yaml b/commands.yaml index 8869138..f2cb70f 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1549,3 +1549,78 @@ personality_tony_quote: - процитируй железного человека - цитата старка action: {type: personality_tony_quote} + +tracker_start: + phrases: + - начни отсчёт + - начни отсчет + - запусти трекер + - я начинаю работу + - начало рабочего дня + action: {type: tracker_start} + +tracker_stop: + phrases: + - закончи отсчёт + - закончи отсчет + - останови трекер + - я закончил работу + - конец рабочего дня + action: {type: tracker_stop} + +tracker_today: + phrases: + - сколько я работаю сегодня + - сколько отработал сегодня + - сколько часов сегодня + - сколько времени за сегодня + action: {type: tracker_today} + +tracker_week: + phrases: + - сколько на этой неделе + - сколько отработал на неделе + - часы за неделю + - статистика за неделю + action: {type: tracker_week} + +tracker_reset: + phrases: + - сбрось трекер + - обнули трекер + - очисти трекер + - сбрось отсчёт + action: {type: tracker_reset} + +outlook_unread_count: + phrases: + - сколько у меня непрочитанных писем + - сколько непрочитанных писем + - проверь почту + - проверь outlook + - сколько новых писем + action: {type: outlook_unread_count} + +outlook_latest: + phrases: + - прочитай последнее письмо + - что в последнем письме + - последнее письмо + - покажи последнее письмо + action: {type: outlook_latest} + +outlook_send_clipboard: + phrases: + - отправь письмо + - отправь почту + - напиши письмо + - отправь это + action: {type: outlook_send_clipboard} + +outlook_summarize_inbox: + phrases: + - коротко расскажи про инбокс + - расскажи что в инбоксе + - сделай саммари инбокса + - что нового в почте + action: {type: outlook_summarize_inbox} diff --git a/extensions.py b/extensions.py index 9cf118f..64f4f02 100644 --- a/extensions.py +++ b/extensions.py @@ -29,6 +29,8 @@ import dev_handlers import llm_backend import wave1_handlers import personality_handlers +import time_tracker_handlers +import outlook_handlers _speak_fn = print _set_clipboard_fn = None @@ -43,6 +45,8 @@ def set_speak(fn): dev_handlers.set_speak(fn) wave1_handlers.set_speak(fn) personality_handlers.set_speak(fn) + time_tracker_handlers.set_speak(fn) + outlook_handlers.set_speak(fn) def set_clipboard_fn(fn): @@ -1130,3 +1134,20 @@ def do_personality_thanks(a, v): personality_handlers.do_personality_thanks(a, v def do_personality_compliment(a, v): personality_handlers.do_personality_compliment(a, v) def do_personality_how_are_you(a, v): personality_handlers.do_personality_how_are_you(a, v) def do_personality_tony_quote(a, v): personality_handlers.do_personality_tony_quote(a, v) + + +# ── Time tracker proxies (to time_tracker_handlers) ───────────────────────── + +def do_tracker_start(a, v): time_tracker_handlers.do_tracker_start(a, v) +def do_tracker_stop(a, v): time_tracker_handlers.do_tracker_stop(a, v) +def do_tracker_today(a, v): time_tracker_handlers.do_tracker_today(a, v) +def do_tracker_week(a, v): time_tracker_handlers.do_tracker_week(a, v) +def do_tracker_reset(a, v): time_tracker_handlers.do_tracker_reset(a, v) + + +# ── Outlook proxies (to outlook_handlers) ─────────────────────────────────── + +def do_outlook_unread_count(a, v): outlook_handlers.do_outlook_unread_count(a, v) +def do_outlook_latest(a, v): outlook_handlers.do_outlook_latest(a, v) +def do_outlook_send_clipboard(a, v): outlook_handlers.do_outlook_send_clipboard(a, v) +def do_outlook_summarize_inbox(a, v): outlook_handlers.do_outlook_summarize_inbox(a, v) diff --git a/main.py b/main.py index 2f1b2bf..1552fae 100644 --- a/main.py +++ b/main.py @@ -1699,6 +1699,24 @@ def run_action(action): extensions.do_personality_how_are_you(action, _current_voice or '') elif t == 'personality_tony_quote': extensions.do_personality_tony_quote(action, _current_voice or '') + elif t == 'tracker_start': + extensions.do_tracker_start(action, _current_voice or '') + elif t == 'tracker_stop': + extensions.do_tracker_stop(action, _current_voice or '') + elif t == 'tracker_today': + extensions.do_tracker_today(action, _current_voice or '') + elif t == 'tracker_week': + extensions.do_tracker_week(action, _current_voice or '') + elif t == 'tracker_reset': + extensions.do_tracker_reset(action, _current_voice or '') + elif t == 'outlook_unread_count': + extensions.do_outlook_unread_count(action, _current_voice or '') + elif t == 'outlook_latest': + extensions.do_outlook_latest(action, _current_voice or '') + elif t == 'outlook_send_clipboard': + extensions.do_outlook_send_clipboard(action, _current_voice or '') + elif t == 'outlook_summarize_inbox': + extensions.do_outlook_summarize_inbox(action, _current_voice or '') _current_voice: str = '' diff --git a/outlook_handlers.py b/outlook_handlers.py new file mode 100644 index 0000000..093046d --- /dev/null +++ b/outlook_handlers.py @@ -0,0 +1,308 @@ +"""Outlook voice-command handlers — Python parity for the Rust `outlook` pack. + +Talks to Outlook via the `win32com.client` COM bridge (pywin32). When pywin32 +isn't installed or Outlook isn't running we degrade gracefully: every handler +reports the failure through the speak callback instead of crashing. + +Exposes four handlers, mirroring the Rust pack: + do_outlook_unread_count(action, voice) + do_outlook_latest(action, voice) + do_outlook_send_clipboard(action, voice) + do_outlook_summarize_inbox(action, voice) +""" + +import re +import subprocess +from typing import Optional + +# Lazy import — pywin32 is Windows-only and may not be installed on dev/CI. +try: + import win32com.client as _w32com # type: ignore + _W32_OK = True +except Exception: # ImportError, OSError, etc. + _w32com = None + _W32_OK = False + + +_speak_fn = print + + +def set_speak(fn): + """Register the voice output callback (called by extensions.set_speak).""" + global _speak_fn + _speak_fn = fn + + +def _speak(text: str) -> None: + try: + _speak_fn(text) + except Exception as exc: + print(f"[outlook] speak: {exc}") + + +# ── Outlook session helpers ──────────────────────────────────────────────── + +def _outlook_session(): + """Return (app, namespace) or (None, None) on any failure.""" + if not _W32_OK: + return None, None + try: + app = _w32com.Dispatch("Outlook.Application") + ns = app.GetNamespace("MAPI") + return app, ns + except Exception as exc: + print(f"[outlook] session: {exc}") + return None, None + + +def _unavailable_msg() -> str: + return "Outlook недоступен — запусти Outlook сначала." + + +def _strip_html(s: str) -> str: + if not s: + return "" + s = re.sub(r"<[^>]+>", " ", s) + s = (s.replace(" ", " ").replace("&", "&") + .replace("<", "<").replace(">", ">") + .replace(""", '"').replace("'", "'")) + return re.sub(r"\s+", " ", s).strip() + + +def _get_clipboard() -> str: + """Fetch clipboard via PowerShell. Returns "" on failure.""" + try: + res = subprocess.run( + ["powershell", "-NoProfile", "-Command", "Get-Clipboard -Raw"], + capture_output=True, text=True, encoding="utf-8", timeout=10, + ) + return (res.stdout or "").strip() + except Exception as exc: + print(f"[outlook] clipboard: {exc}") + return "" + + +# ── handlers ─────────────────────────────────────────────────────────────── + +def do_outlook_unread_count(action, voice): + _, ns = _outlook_session() + if ns is None: + _speak(_unavailable_msg()) + return + try: + inbox = ns.GetDefaultFolder(6) # olFolderInbox + # Prefer the cheap property; fall back to scanning items. + try: + count = int(inbox.UnReadItemCount) + except Exception: + count = sum(1 for it in inbox.Items if getattr(it, "UnRead", False)) + except Exception as exc: + print(f"[outlook] unread_count: {exc}") + _speak(_unavailable_msg()) + return + + if count == 0: + _speak("Непрочитанных писем нет.") + elif count == 1: + _speak("Одно непрочитанное письмо.") + else: + _speak(f"Непрочитанных писем: {count}.") + + +def do_outlook_latest(action, voice): + _, ns = _outlook_session() + if ns is None: + _speak(_unavailable_msg()) + return + try: + inbox = ns.GetDefaultFolder(6) + items = inbox.Items + items.Sort("[ReceivedTime]", True) + latest = None + for it in items: + try: + if it.Class == 43: # olMail + latest = it + break + except Exception: + continue + if latest is None: + _speak("Входящие пусты.") + return + from_name = ( + getattr(latest, "SenderName", "") + or getattr(latest, "SenderEmailAddress", "") + or "неизвестного отправителя" + ) + subject = getattr(latest, "Subject", "") or "без темы" + body = getattr(latest, "Body", "") or "" + if not body: + body = _strip_html(getattr(latest, "HTMLBody", "") or "") + body = re.sub(r"\s+", " ", body).strip() + if len(body) > 200: + body = body[:200] + "..." + except Exception as exc: + print(f"[outlook] latest: {exc}") + _speak(_unavailable_msg()) + return + + _speak(f"Последнее письмо от {from_name}. Тема: {subject}. {body}") + + +_SEND_TRIGGERS = ( + "отправь письмо ", "отправь почту ", "напиши письмо ", "отправь это ", + "email this to ", "send email to ", "send this email to ", "mail this to ", +) + + +def _extract_recipient(voice: str) -> str: + """Pull the recipient name off the voice command by stripping the trigger.""" + if not voice: + return "" + low = voice.lower() + for trig in _SEND_TRIGGERS: + if trig in low: + idx = low.find(trig) + return voice[idx + len(trig):].strip() + return voice.strip() + + +def _resolve_recipient(ns, query: str) -> Optional[dict]: + if not query or ns is None: + return None + try: + r = ns.CreateRecipient(query) + r.Resolve() + if not r.Resolved: + return None + addr = None + try: + addr = r.AddressEntry.GetExchangeUser().PrimarySmtpAddress + except Exception: + pass + if not addr: + try: + addr = r.AddressEntry.Address + except Exception: + pass + if not addr: + addr = r.Name + return {"name": r.Name, "address": addr} + except Exception as exc: + print(f"[outlook] resolve: {exc}") + return None + + +def do_outlook_send_clipboard(action, voice): + recipient = _extract_recipient(voice or "") + if not recipient: + _speak("Кому отправить? Назови имя.") + return + + app, ns = _outlook_session() + if ns is None or app is None: + _speak(_unavailable_msg()) + return + + resolved = _resolve_recipient(ns, recipient) + if not resolved: + _speak(f"Не нашёл контакт «{recipient}».") + return + + body = _get_clipboard() + first_line = (body.split("\n", 1)[0] if body else "").strip() + if len(first_line) > 60: + subject = first_line[:60].strip() + else: + subject = first_line + if not subject: + subject = "From J.A.R.V.I.S." + + try: + mail = app.CreateItem(0) # olMailItem + mail.To = resolved["address"] + mail.Subject = subject + mail.Body = body + mail.Send() + except Exception as exc: + print(f"[outlook] send: {exc}") + _speak("Не получилось отправить — Outlook отказал.") + return + + _speak(f"Отправил {resolved['name']}. Тема: {subject}.") + + +def _llm_summarize(emails) -> Optional[str]: + """Call the configured LLM to summarise the email list. Returns None on + any failure, callers should provide a fallback.""" + try: + import llm_backend + except Exception: + return None + if not emails: + return None + listing = "\n".join( + f"{i+1}. From: {m['from']} | Subject: {m['subject']}" + for i, m in enumerate(emails) + ) + sys_prompt = ( + "Ты помощник по почте. Кратко опиши, что лежит в инбоксе, ОДНИМ абзацем " + "(2-4 предложения). Сгруппируй по темам/отправителям если возможно. " + "Без преамбулы, без перечисления списком." + ) + user_prompt = "Непрочитанные письма:\n" + listing + try: + reply = llm_backend.complete( + messages=[ + {"role": "system", "content": sys_prompt}, + {"role": "user", "content": user_prompt}, + ], + max_tokens=250, + temperature=0.4, + ) + if reply and isinstance(reply, str) and reply.strip(): + return reply.strip() + except Exception as exc: + print(f"[outlook] llm: {exc}") + return None + + +def do_outlook_summarize_inbox(action, voice, limit: int = 5): + _, ns = _outlook_session() + if ns is None: + _speak(_unavailable_msg()) + return + try: + inbox = ns.GetDefaultFolder(6) + items = inbox.Items + items.Sort("[ReceivedTime]", True) + emails = [] + for it in items: + if len(emails) >= limit: + break + try: + if it.Class != 43: + continue + if not getattr(it, "UnRead", False): + continue + except Exception: + continue + emails.append({ + "from": getattr(it, "SenderName", "") or "", + "subject": getattr(it, "Subject", "") or "", + }) + except Exception as exc: + print(f"[outlook] summarize: {exc}") + _speak(_unavailable_msg()) + return + + if not emails: + _speak("Непрочитанных писем нет.") + return + + summary = _llm_summarize(emails) + if not summary: + # Fallback to a flat enumeration. + parts = "; ".join(f"{m['from']} — {m['subject']}" for m in emails) + summary = f"Непрочитанных: {len(emails)}. {parts}." + _speak(summary) diff --git a/requirements.txt b/requirements.txt index 070fb42..17c4804 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,6 +27,10 @@ sounddevice PyYAML python-dotenv +# Outlook COM automation (Win32-only). Optional: outlook_handlers degrades +# gracefully when this isn't installed. +pywin32>=305 + # Semantic intent classifier (MiniLM-L6-v2 ONNX) onnxruntime>=1.17 tokenizers>=0.15 diff --git a/tests/test_outlook_handlers.py b/tests/test_outlook_handlers.py new file mode 100644 index 0000000..7c149e6 --- /dev/null +++ b/tests/test_outlook_handlers.py @@ -0,0 +1,103 @@ +"""Tests for outlook_handlers — COM bridge mocked via unittest.mock. + +We patch the module-level `_outlook_session` to return a fake (app, ns) pair +made of MagicMocks so we never need a live Outlook install. +""" + +import importlib +import sys +from unittest.mock import MagicMock, patch + + +def _fresh_module(): + if 'outlook_handlers' in sys.modules: + del sys.modules['outlook_handlers'] + return importlib.import_module('outlook_handlers') + + +def _make_inbox_with_unread(count): + """Build a fake Inbox object whose UnReadItemCount is `count`.""" + inbox = MagicMock() + inbox.UnReadItemCount = count + return inbox + + +def test_unread_count_speaks_singular_for_one(): + mod = _fresh_module() + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + + app = MagicMock() + ns = MagicMock() + ns.GetDefaultFolder.return_value = _make_inbox_with_unread(1) + with patch.object(mod, '_outlook_session', return_value=(app, ns)): + mod.do_outlook_unread_count({}, '') + + assert spoken, "expected the handler to speak" + assert "Одно" in spoken[0] + + +def test_unread_count_zero_says_no_unread(): + mod = _fresh_module() + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + + app = MagicMock() + ns = MagicMock() + ns.GetDefaultFolder.return_value = _make_inbox_with_unread(0) + with patch.object(mod, '_outlook_session', return_value=(app, ns)): + mod.do_outlook_unread_count({}, '') + + assert spoken + assert "нет" in spoken[0].lower() + + +def test_unread_count_plural_count(): + mod = _fresh_module() + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + + ns = MagicMock() + ns.GetDefaultFolder.return_value = _make_inbox_with_unread(7) + with patch.object(mod, '_outlook_session', return_value=(MagicMock(), ns)): + mod.do_outlook_unread_count({}, '') + + assert "7" in spoken[0] + + +def test_unavailable_outlook_says_so(): + mod = _fresh_module() + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + + with patch.object(mod, '_outlook_session', return_value=(None, None)): + mod.do_outlook_unread_count({}, '') + + assert spoken + assert "Outlook" in spoken[0] + + +def test_extract_recipient_strips_trigger(): + mod = _fresh_module() + assert mod._extract_recipient("отправь письмо Дмитрию") == "Дмитрию" + assert mod._extract_recipient("email this to Bob") == "Bob" + assert mod._extract_recipient("Bob") == "Bob" + assert mod._extract_recipient("") == "" + + +def test_send_clipboard_no_recipient_prompts_for_name(): + mod = _fresh_module() + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + + # No trigger and no recipient in the voice -> _extract_recipient returns "" + mod.do_outlook_send_clipboard({}, "") + + assert spoken + assert "имя" in spoken[0].lower() or "name" in spoken[0].lower() + + +def test_strip_html_basic(): + mod = _fresh_module() + assert mod._strip_html("Hi there") == "Hi there" + assert mod._strip_html("") == "" diff --git a/tests/test_time_tracker.py b/tests/test_time_tracker.py new file mode 100644 index 0000000..575524c --- /dev/null +++ b/tests/test_time_tracker.py @@ -0,0 +1,179 @@ +"""Tests for time_tracker_handlers — parity with rust time_tracker pack.""" + +import json +import os +import time as _time +import pytest + + +def test_load_returns_empty_when_file_missing(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + data = tt.load() + assert data == {'current_session_start': None, 'sessions': []} + + +def test_start_session_records_timestamp(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + tt.start_session(now=1_700_000_000) + data = tt.load() + assert data['current_session_start'] == 1_700_000_000 + assert data['sessions'] == [] + + +def test_start_session_is_idempotent_when_already_running(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + tt.start_session(now=1_700_000_000) + tt.start_session(now=1_700_010_000) # second start should not overwrite + data = tt.load() + assert data['current_session_start'] == 1_700_000_000 + + +def test_stop_session_pushes_completed(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + tt.start_session(now=1_700_000_000) + _, elapsed = tt.stop_session(now=1_700_003_600) # +1 hour + assert elapsed == 3600 + data = tt.load() + assert data['current_session_start'] is None + assert len(data['sessions']) == 1 + assert data['sessions'][0]['start'] == 1_700_000_000 + assert data['sessions'][0]['end'] == 1_700_003_600 + + +def test_stop_session_without_open_returns_none(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + _, elapsed = tt.stop_session(now=1_700_000_000) + assert elapsed is None + + +def test_today_total_sums_overlap_in_local_day(isolated_state): + """Two sessions today + one yesterday — only today's pair is counted.""" + tt = isolated_state('time_tracker_handlers', ['_PATH']) + + # Pick a deterministic "now" at 15:00 local time today. + today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d'))) + now = today_midnight + 15 * 3600 # 15:00 local today + + data = { + 'current_session_start': None, + 'sessions': [ + # yesterday: 10:00 → 12:00 (2h, ignored) + {'start': today_midnight - 14 * 3600, 'end': today_midnight - 12 * 3600}, + # today: 09:00 → 10:30 (90 min) + {'start': today_midnight + 9 * 3600, 'end': today_midnight + 10 * 3600 + 1800}, + # today: 13:00 → 14:00 (60 min) + {'start': today_midnight + 13 * 3600, 'end': today_midnight + 14 * 3600}, + ], + } + tt.save(data) + + total = tt.today_total(now=now) + assert total == 90 * 60 + 60 * 60 # 2h30m + + +def test_today_total_includes_open_session(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d'))) + now = today_midnight + 10 * 3600 # 10:00 + data = { + 'current_session_start': today_midnight + 9 * 3600, # opened at 09:00 + 'sessions': [], + } + tt.save(data) + total = tt.today_total(now=now) + assert total == 3600 # 1h of open session counts + + +def test_week_total_covers_last_seven_days(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d'))) + now = today_midnight + 12 * 3600 + + data = { + 'current_session_start': None, + 'sessions': [ + # 8 days ago: outside window + {'start': today_midnight - 8 * 86400, 'end': today_midnight - 8 * 86400 + 3600}, + # 3 days ago: inside window (1h) + {'start': today_midnight - 3 * 86400, 'end': today_midnight - 3 * 86400 + 3600}, + # today: 2h + {'start': today_midnight + 9 * 3600, 'end': today_midnight + 11 * 3600}, + ], + } + tt.save(data) + + total = tt.week_total(now=now) + assert total == 3 * 3600 # 3 hours + + +def test_reset_today_drops_only_todays_sessions(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d'))) + now = today_midnight + 14 * 3600 + data = { + 'current_session_start': today_midnight + 13 * 3600, + 'sessions': [ + {'start': today_midnight - 86400, 'end': today_midnight - 86400 + 3600}, # yesterday + {'start': today_midnight + 9 * 3600, 'end': today_midnight + 10 * 3600}, # today + {'start': today_midnight + 11 * 3600, 'end': today_midnight + 12 * 3600}, # today + ], + } + tt.save(data) + + dropped, had_open = tt.reset_today(now=now) + assert dropped == 2 + assert had_open is True + + after = tt.load() + assert after['current_session_start'] is None + assert len(after['sessions']) == 1 + assert after['sessions'][0]['start'] == today_midnight - 86400 + + +def test_reset_today_when_empty_reports_zero(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + dropped, had_open = tt.reset_today(now=1_700_000_000) + assert dropped == 0 + assert had_open is False + + +def test_format_duration_ru_pluralisation(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + assert tt.format_duration_ru(0) == "0 минут" + assert tt.format_duration_ru(60) == "1 минута" + assert tt.format_duration_ru(2 * 60) == "2 минуты" + assert tt.format_duration_ru(5 * 60) == "5 минут" + assert tt.format_duration_ru(11 * 60) == "11 минут" + assert tt.format_duration_ru(3600) == "1 час" + assert tt.format_duration_ru(2 * 3600) == "2 часа" + assert tt.format_duration_ru(5 * 3600) == "5 часов" + assert tt.format_duration_ru(3 * 3600 + 24 * 60) == "3 часа 24 минуты" + + +def test_persists_atomic_write(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + tt.start_session(now=1_700_000_000) + assert os.path.isfile(tt._PATH) + with open(tt._PATH, encoding='utf-8') as f: + on_disk = json.load(f) + assert on_disk['current_session_start'] == 1_700_000_000 + + +def test_voice_entry_speaks_start_message(isolated_state, monkeypatch): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + spoken = [] + monkeypatch.setattr(tt, '_speak_fn', lambda s: spoken.append(s)) + tt.do_tracker_start({}, '') + assert spoken and 'Отсчёт' in spoken[0] + # second call says already running + tt.do_tracker_start({}, '') + assert any('Уже считаю' in s for s in spoken) + + +def test_voice_entry_today_reports_total(isolated_state, monkeypatch): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + spoken = [] + monkeypatch.setattr(tt, '_speak_fn', lambda s: spoken.append(s)) + # No data → "ничего не отработано" + tt.do_tracker_today({}, '') + assert spoken and 'ничего не отработано' in spoken[-1].lower() diff --git a/time_tracker_handlers.py b/time_tracker_handlers.py new file mode 100644 index 0000000..b0fb7d2 --- /dev/null +++ b/time_tracker_handlers.py @@ -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 `/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))