diff --git a/src/citrus_time_tracker/app.py b/src/citrus_time_tracker/app.py index c2cfefb..91956ef 100644 --- a/src/citrus_time_tracker/app.py +++ b/src/citrus_time_tracker/app.py @@ -1,4 +1,5 @@ import json +import logging import time import threading from pathlib import Path @@ -17,6 +18,9 @@ from .youtrack_client import YouTrackClient if not load_dotenv(".env"): load_dotenv(".env.example") +logging.basicConfig(level=logging.WARNING) +logger = logging.getLogger("TimeTracker") + class Application: def __init__(self): self._lock = threading.Lock() @@ -39,6 +43,11 @@ class Application: if self.config.current_task_id: self.current_task = self._load_task(self.config.current_task_id) + try: + self.sync_with_youtrack() + except Exception as e: + logger.error(f"Ошибка при начальной синхронизации: {e}") + ensure_dir(self.tasks_dir) # Запускаем слушатели активности (только если tracking_enabled) @@ -61,7 +70,7 @@ class Application: window_info = self.platform.get_active_window() group_name = self._match_window_to_policy(window_info) is_blocked = self._is_group_blocked(group_name) - is_unknown = (group_name == "Неизвестное приложение") + is_unknown = (group_name == "Unknown app") # Считаем "рабочим", если не blocked и не unknown if not is_blocked and not is_unknown: @@ -120,7 +129,7 @@ class Application: for pname in policy.process_names: if pname.lower() == proc: return policy.group_name - return "Неизвестное приложение" + return "Unknown app" def _is_group_blocked(self, group_name: str) -> bool: for policy in self.config.window_policies: @@ -161,10 +170,6 @@ class Application: """Сохраняет текущую задачу, если она есть""" if self.current_task: - try: - self.sync_with_youtrack() - except: - pass self.save_task(self.current_task) def toggle_tracking(self): @@ -197,6 +202,10 @@ class Application: self.config.tracking_enabled = True self.tracking_enabled = True self._save_config(self.config) + try: + self.sync_with_youtrack() + except Exception as e: + logger.error(f"Ошибка при синхронизации после создания задачи: {e}") def select_task(self, task_id: str): with self._lock: @@ -208,6 +217,11 @@ class Application: self.tracking_enabled = True self._save_config(self.config) + try: + self.sync_with_youtrack() + except Exception as e: + logger.error(f"Ошибка при синхронизации после выбора задачи: {e}") + def add_manual_time(self, minutes: float): with self._lock: if not self.current_task: @@ -273,9 +287,7 @@ class Application: return None def save_task(self, task: TrackedTask) -> None: - task_file = self.tasks_dir / f"{task.task_id}.json" - with open(task_file, "w", encoding="utf-8") as f: - f.write(task.model_dump_json(indent=2)) + self._save_task_no_sync(task) def shutdown(self): self._running = False @@ -286,28 +298,127 @@ class Application: self.keyboard_listener.stop() def sync_with_youtrack(self): - """Синхронизирует все несинхронизированные сессии текущей задачи с YouTrack""" + """ + Двусторонняя синхронизация с YouTrack: + 1. Проверяет существование задачи в YouTrack + 2. Загружает все work items из YouTrack + 3. Сопоставляет локальные сессии с удаленными + 4. Отправляет/обновляет несинхронизированные локальные сессии + 5. Добавляет новые сессии из YouTrack, которых нет локально + """ if not self.current_task: return client = YouTrackClient() if not client.enabled: + logger.info("YouTrack: синхронизация отключена (нет настроек в .env)") return - # Проверяем, существует ли задача + # Проверяем существование задачи в YouTrack if not client.issue_exists(self.current_task.task_id): + logger.warning(f"YouTrack: задача {self.current_task.task_id} не существует — синхронизация пропущена") return - changed = False - for session in self.current_task.sessions: - if not session.synchronized: + # Загружаем все work items из YouTrack + remote_items = client.get_issue_work_items(self.current_task.task_id) + if not remote_items: + logger.info(f"YouTrack: нет записей времени для задачи {self.current_task.task_id}") + + with self._lock: + changed = False + + # Шаг 1: Обрабатываем локальные сессии + for session in self.current_task.sessions: total_minutes = sum(detail.duration_seconds for detail in session.details) / 60.0 - if client.add_work_item(self.current_task.task_id, total_minutes): + + # Пропускаем нулевые или отрицательные сессии + if total_minutes <= 0: session.synchronized = True changed = True - else: - # Если ошибка — не помечаем как синхронизированную (повторим позже) - pass + continue - if changed: - self.save_task(self.current_task) \ No newline at end of file + # Случай A: Сессия уже имеет ID — обновляем существующий work item + if session.id: + if not session.synchronized: + if client.update_work_item(self.current_task.task_id, session.id, total_minutes): + session.synchronized = True + changed = True + logger.info(f"Обновлена сессия {session.id} для задачи {self.current_task.task_id}") + else: + logger.warning(f"Не удалось обновить сессию {session.id}") + # Если synchronized=True — ничего не делаем + + # Случай B: Сессия без ID, но помечена как синхронизированная + # (возможно, синхронизирована в предыдущих версиях без сохранения ID) + elif session.synchronized: + # Пропускаем — считаем, что время уже учтено в YouTrack + # Но для надежности можно попробовать найти совпадение по длительности + matching_remote = next( + (item for item in remote_items + if abs(item['minutes'] - total_minutes) < 1), # погрешность 1 минута + None + ) + if matching_remote: + session.id = matching_remote['id'] + # Удаляем из remote_items, чтобы не дублировать при добавлении + remote_items.remove(matching_remote) + logger.info(f"Сопоставлена сессия без ID с удаленной записью {session.id}") + changed = True + + # Случай C: Сессия без ID и не синхронизирована — создаем новый work item + else: + work_item_id = client.add_work_item( + self.current_task.task_id, + total_minutes, + description=f"Auto-tracked: {', '.join(set(d.group_name for d in session.details))}" + ) + if work_item_id: + session.id = work_item_id + session.synchronized = True + # Сохраняем время создания для будущего сопоставления + session.youtrack_created_at = datetime.fromtimestamp( + next((item['created'] / 1000 for item in remote_items if item['id'] == work_item_id), + time.time()) + ) + changed = True + logger.info( + f"Создана новая запись времени {work_item_id} для задачи {self.current_task.task_id}") + else: + logger.warning(f"Не удалось создать запись времени для сессии") + + # Шаг 2: Добавляем удаленные work items, которых нет локально + # (например, добавленные вручную через веб-интерфейс YouTrack) + local_session_ids = {s.id for s in self.current_task.sessions if s.id} + + for remote_item in remote_items: + if remote_item['id'] not in local_session_ids and remote_item['minutes'] > 0: + # Создаем новую сессию на основе удаленной записи + now = datetime.now() + new_session = WorkSession( + start_time=now, + end_time=now, + details=[WorkDetail( + group_name="YouTrack Manual", + duration_seconds=remote_item['minutes'] * 60 + )], + synchronized=True, + id=remote_item['id'], + youtrack_created_at=datetime.fromtimestamp(remote_item['created'] / 1000) if remote_item.get( + 'created') else now, + description=remote_item.get('text') + ) + self.current_task.sessions.append(new_session) + changed = True + logger.info( + f"Добавлена удаленная запись времени {remote_item['id']} ({remote_item['minutes']} мин) в локальную задачу") + + # Сохраняем задачу, если были изменения + if changed: + self._save_task_no_sync(self.current_task) + logger.info(f"Синхронизация завершена для задачи {self.current_task.task_id}") + + def _save_task_no_sync(self, task: TrackedTask) -> None: + """Сохраняет задачу БЕЗ повторной синхронизации (во избежание рекурсии)""" + task_file = self.tasks_dir / f"{task.task_id}.json" + with open(task_file, "w", encoding="utf-8") as f: + f.write(task.model_dump_json(indent=2)) \ No newline at end of file diff --git a/src/citrus_time_tracker/models.py b/src/citrus_time_tracker/models.py index 5e2c5e7..5ab9786 100644 --- a/src/citrus_time_tracker/models.py +++ b/src/citrus_time_tracker/models.py @@ -14,6 +14,14 @@ class WorkSession(BaseModel): end_time: datetime details: List[WorkDetail] synchronized: bool = False + id: Optional[str] = None # ID work item в YouTrack + youtrack_created_at: Optional[datetime] = None # Время создания в YouTrack для сопоставления + description: Optional[str] = None # Описание для сопоставления (опционально) + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() + } class TrackedTask(BaseModel): @@ -71,4 +79,16 @@ class TrackerConfig(BaseModel): def __init__(self, **data): super().__init__(**data) if not self.window_policies: - self.window_policies = self.default_policies() \ No newline at end of file + self.window_policies = self.default_policies() + + +class YouTrackTimeTrackerEntryDuration(BaseModel): + minutes: int + +class YouTrackTimeTrackerEntry(BaseModel): + id: str + duration: YouTrackTimeTrackerEntryDuration + + @property + def minutes(self) -> int: + return self.duration.minutes diff --git a/src/citrus_time_tracker/ui/overlay.py b/src/citrus_time_tracker/ui/overlay.py index f544f07..3308ccf 100644 --- a/src/citrus_time_tracker/ui/overlay.py +++ b/src/citrus_time_tracker/ui/overlay.py @@ -74,7 +74,7 @@ class OverlayWindow: self.session_timer_label.pack(pady=(5, 0)) # Текущее окно - self.window_group_label = Label(main_frame, text="Неизвестное приложение", anchor="w", fg="gray") + self.window_group_label = Label(main_frame, text="Unknown app", anchor="w", fg="gray") self.window_group_label.pack(anchor="w") self.window_detail_label = Label(main_frame, text="", anchor="w", font=("TkDefaultFont", 8), fg="gray") diff --git a/src/citrus_time_tracker/youtrack_client.py b/src/citrus_time_tracker/youtrack_client.py index 4f21469..4f1e02e 100644 --- a/src/citrus_time_tracker/youtrack_client.py +++ b/src/citrus_time_tracker/youtrack_client.py @@ -1,8 +1,14 @@ +import json +from typing import List, Optional, Tuple +from datetime import datetime + import httpx import os import logging from pathlib import Path +from citrus_time_tracker.models import YouTrackTimeTrackerEntry + logging.basicConfig(level=logging.WARNING) logger = logging.getLogger("YouTrackClient") @@ -57,25 +63,95 @@ class YouTrackClient: logger.error(f"YouTrack: ошибка при проверке задачи {issue_id}: {e}") return False - def add_work_item(self, issue_id: str, minutes: float) -> bool: + def add_work_item(self, issue_id: str, minutes: float, description: Optional[str] = None) -> Optional[str]: + """ + Создаёт новый work item и возвращает его ID + """ if not self.enabled: - return False + return None if minutes <= 0: - return True + return None - payload = {"duration": {"minutes": round(minutes)}} + payload: dict = { + "duration": {"minutes": round(minutes)}, + "date": int(datetime.now().timestamp() * 1000) # milliseconds + } + if description: + payload["text"] = description try: with self._get_client() as client: url = f"{self.base_url}/api/issues/{issue_id}/timeTracking/workItems" resp = client.post(url, headers=self.headers, json=payload) if resp.status_code in (200, 201): - logger.info(f"YouTrack: +{round(minutes)} мин к {issue_id}") + data = resp.json() + work_item_id = data.get('id') + logger.info(f"YouTrack: +{round(minutes)} мин к {issue_id} (ID: {work_item_id})") + return work_item_id + else: + logger.error(f"YouTrack: ошибка создания времени ({issue_id}): {resp.status_code} – {resp.text}") + return None + except Exception as e: + logger.error(f"YouTrack: сетевая ошибка при создании времени ({issue_id}): {e}") + return None + + def update_work_item(self, issue_id: str, work_item_id: str, minutes: float) -> bool: + """ + Обновляет существующий work item + """ + if not self.enabled: + return False + + if minutes <= 0: + return False + + payload = { + "duration": {"minutes": round(minutes)}, + "id": work_item_id + } + + try: + with self._get_client() as client: + url = f"{self.base_url}/api/issues/{issue_id}/timeTracking/workItems/{work_item_id}" + resp = client.post(url, headers=self.headers, json=payload) # YouTrack использует POST для обновления + if resp.status_code in (200, 201): + logger.info(f"YouTrack: обновлено время {work_item_id} на {round(minutes)} мин для {issue_id}") return True else: - logger.error(f"YouTrack: ошибка отправки времени ({issue_id}): {resp.status_code} – {resp.text}") + logger.error( + f"YouTrack: ошибка обновления времени ({work_item_id}): {resp.status_code} – {resp.text}") return False except Exception as e: - logger.error(f"YouTrack: сетевая ошибка при отправке времени ({issue_id}): {e}") - return False \ No newline at end of file + logger.error(f"YouTrack: сетевая ошибка при обновлении времени ({work_item_id}): {e}") + return False + + def get_issue_work_items(self, issue_id: str) -> List[dict]: + """ + Возвращает полные данные work items с метаданными для синхронизации + """ + if not self.enabled: + return [] + try: + with self._get_client() as client: + # Запрашиваем больше полей для точного сопоставления + url = f"{self.base_url}/api/issues/{issue_id}/timeTracking/workItems" + url += "?fields=id,duration(minutes),created,text" + resp = client.get(url, headers=self.headers) + if resp.status_code != 200: + logger.error(f"YouTrack: ошибка получения work items ({issue_id}): {resp.status_code}") + return [] + + data = resp.json() + work_items = [] + for item in data: + work_items.append({ + 'id': item.get('id'), + 'minutes': item.get('duration', {}).get('minutes', 0), + 'created': item.get('created'), # timestamp в миллисекундах + 'text': item.get('text', '') + }) + return work_items + except Exception as e: + logger.error(f"YouTrack: сетевая ошибка при получении work items для {issue_id}: {e}") + return [] \ No newline at end of file