Better sync
This commit is contained in:
@@ -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)
|
||||
# Случай 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))
|
||||
@@ -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()
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
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 []
|
||||
Reference in New Issue
Block a user