Better sync

This commit is contained in:
Mikan
2026-03-12 18:50:39 +03:00
parent 4b56ec4641
commit 1afa6f71ca
4 changed files with 237 additions and 30 deletions

View File

@@ -1,4 +1,5 @@
import json import json
import logging
import time import time
import threading import threading
from pathlib import Path from pathlib import Path
@@ -17,6 +18,9 @@ from .youtrack_client import YouTrackClient
if not load_dotenv(".env"): if not load_dotenv(".env"):
load_dotenv(".env.example") load_dotenv(".env.example")
logging.basicConfig(level=logging.WARNING)
logger = logging.getLogger("TimeTracker")
class Application: class Application:
def __init__(self): def __init__(self):
self._lock = threading.Lock() self._lock = threading.Lock()
@@ -39,6 +43,11 @@ class Application:
if self.config.current_task_id: if self.config.current_task_id:
self.current_task = self._load_task(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) ensure_dir(self.tasks_dir)
# Запускаем слушатели активности (только если tracking_enabled) # Запускаем слушатели активности (только если tracking_enabled)
@@ -61,7 +70,7 @@ class Application:
window_info = self.platform.get_active_window() window_info = self.platform.get_active_window()
group_name = self._match_window_to_policy(window_info) group_name = self._match_window_to_policy(window_info)
is_blocked = self._is_group_blocked(group_name) is_blocked = self._is_group_blocked(group_name)
is_unknown = (group_name == "Неизвестное приложение") is_unknown = (group_name == "Unknown app")
# Считаем "рабочим", если не blocked и не unknown # Считаем "рабочим", если не blocked и не unknown
if not is_blocked and not is_unknown: if not is_blocked and not is_unknown:
@@ -120,7 +129,7 @@ class Application:
for pname in policy.process_names: for pname in policy.process_names:
if pname.lower() == proc: if pname.lower() == proc:
return policy.group_name return policy.group_name
return "Неизвестное приложение" return "Unknown app"
def _is_group_blocked(self, group_name: str) -> bool: def _is_group_blocked(self, group_name: str) -> bool:
for policy in self.config.window_policies: for policy in self.config.window_policies:
@@ -161,10 +170,6 @@ class Application:
"""Сохраняет текущую задачу, если она есть""" """Сохраняет текущую задачу, если она есть"""
if self.current_task: if self.current_task:
try:
self.sync_with_youtrack()
except:
pass
self.save_task(self.current_task) self.save_task(self.current_task)
def toggle_tracking(self): def toggle_tracking(self):
@@ -197,6 +202,10 @@ class Application:
self.config.tracking_enabled = True self.config.tracking_enabled = True
self.tracking_enabled = True self.tracking_enabled = True
self._save_config(self.config) 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): def select_task(self, task_id: str):
with self._lock: with self._lock:
@@ -208,6 +217,11 @@ class Application:
self.tracking_enabled = True self.tracking_enabled = True
self._save_config(self.config) 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): def add_manual_time(self, minutes: float):
with self._lock: with self._lock:
if not self.current_task: if not self.current_task:
@@ -273,9 +287,7 @@ class Application:
return None return None
def save_task(self, task: TrackedTask) -> None: def save_task(self, task: TrackedTask) -> None:
task_file = self.tasks_dir / f"{task.task_id}.json" self._save_task_no_sync(task)
with open(task_file, "w", encoding="utf-8") as f:
f.write(task.model_dump_json(indent=2))
def shutdown(self): def shutdown(self):
self._running = False self._running = False
@@ -286,28 +298,127 @@ class Application:
self.keyboard_listener.stop() self.keyboard_listener.stop()
def sync_with_youtrack(self): def sync_with_youtrack(self):
"""Синхронизирует все несинхронизированные сессии текущей задачи с YouTrack""" """
Двусторонняя синхронизация с YouTrack:
1. Проверяет существование задачи в YouTrack
2. Загружает все work items из YouTrack
3. Сопоставляет локальные сессии с удаленными
4. Отправляет/обновляет несинхронизированные локальные сессии
5. Добавляет новые сессии из YouTrack, которых нет локально
"""
if not self.current_task: if not self.current_task:
return return
client = YouTrackClient() client = YouTrackClient()
if not client.enabled: if not client.enabled:
logger.info("YouTrack: синхронизация отключена (нет настроек в .env)")
return return
# Проверяем, существует ли задача # Проверяем существование задачи в YouTrack
if not client.issue_exists(self.current_task.task_id): if not client.issue_exists(self.current_task.task_id):
logger.warning(f"YouTrack: задача {self.current_task.task_id} не существует — синхронизация пропущена")
return return
# Загружаем все 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 changed = False
# Шаг 1: Обрабатываем локальные сессии
for session in self.current_task.sessions: for session in self.current_task.sessions:
if not session.synchronized:
total_minutes = sum(detail.duration_seconds for detail in session.details) / 60.0 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 session.synchronized = True
changed = True changed = True
else: continue
# Если ошибка — не помечаем как синхронизированную (повторим позже)
pass
# Случай 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: if changed:
self.save_task(self.current_task) 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))

View File

@@ -14,6 +14,14 @@ class WorkSession(BaseModel):
end_time: datetime end_time: datetime
details: List[WorkDetail] details: List[WorkDetail]
synchronized: bool = False 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): class TrackedTask(BaseModel):
@@ -72,3 +80,15 @@ class TrackerConfig(BaseModel):
super().__init__(**data) super().__init__(**data)
if not self.window_policies: if not self.window_policies:
self.window_policies = self.default_policies() 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

View File

@@ -74,7 +74,7 @@ class OverlayWindow:
self.session_timer_label.pack(pady=(5, 0)) 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_group_label.pack(anchor="w")
self.window_detail_label = Label(main_frame, text="", anchor="w", font=("TkDefaultFont", 8), fg="gray") self.window_detail_label = Label(main_frame, text="", anchor="w", font=("TkDefaultFont", 8), fg="gray")

View File

@@ -1,8 +1,14 @@
import json
from typing import List, Optional, Tuple
from datetime import datetime
import httpx import httpx
import os import os
import logging import logging
from pathlib import Path from pathlib import Path
from citrus_time_tracker.models import YouTrackTimeTrackerEntry
logging.basicConfig(level=logging.WARNING) logging.basicConfig(level=logging.WARNING)
logger = logging.getLogger("YouTrackClient") logger = logging.getLogger("YouTrackClient")
@@ -57,25 +63,95 @@ class YouTrackClient:
logger.error(f"YouTrack: ошибка при проверке задачи {issue_id}: {e}") logger.error(f"YouTrack: ошибка при проверке задачи {issue_id}: {e}")
return False 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: if not self.enabled:
return False return None
if minutes <= 0: 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: try:
with self._get_client() as client: with self._get_client() as client:
url = f"{self.base_url}/api/issues/{issue_id}/timeTracking/workItems" url = f"{self.base_url}/api/issues/{issue_id}/timeTracking/workItems"
resp = client.post(url, headers=self.headers, json=payload) resp = client.post(url, headers=self.headers, json=payload)
if resp.status_code in (200, 201): 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 return True
else: 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 return False
except Exception as e: except Exception as e:
logger.error(f"YouTrack: сетевая ошибка при отправке времени ({issue_id}): {e}") logger.error(f"YouTrack: сетевая ошибка при обновлении времени ({work_item_id}): {e}")
return False 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 []