Compare commits
4 Commits
4b56ec4641
...
332bc0a150
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
332bc0a150 | ||
|
|
534aa9a7d1 | ||
|
|
0f8a86e536 | ||
|
|
1afa6f71ca |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "citrus_time_tracker"
|
name = "citrus_time_tracker"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
description = "Time tracking tool with YouTrack integration"
|
description = "Time tracking tool with YouTrack integration"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{name = "Mikan"}]
|
authors = [{name = "Mikan"}]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -34,11 +38,17 @@ class Application:
|
|||||||
self._tracking_thread: Optional[threading.Thread] = None
|
self._tracking_thread: Optional[threading.Thread] = None
|
||||||
self._running = True
|
self._running = True
|
||||||
self.session_start_time: Optional[float] = None
|
self.session_start_time: Optional[float] = None
|
||||||
|
self.idle_start_time: Optional[float] = None
|
||||||
|
|
||||||
# Загружаем задачу
|
# Загружаем задачу
|
||||||
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 +71,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:
|
||||||
@@ -82,32 +92,110 @@ class Application:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
if not self.tracking_enabled or not self.current_task:
|
if not self.tracking_enabled or not self.current_task:
|
||||||
self.session_start_time = None
|
self.session_start_time = None
|
||||||
|
self.idle_start_time = None
|
||||||
return
|
return
|
||||||
|
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
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)
|
||||||
|
current_idle = (current_time - self.last_activity > self.config.idle_threshold_seconds) or is_blocked
|
||||||
|
|
||||||
was_idle = self.is_idle
|
was_idle = self.is_idle
|
||||||
|
self.is_idle = current_idle
|
||||||
|
|
||||||
if current_time - self.last_activity > 60 or is_blocked:
|
# === СЛУЧАЙ 1: Переход в бездействие ===
|
||||||
self.is_idle = True
|
if not was_idle and current_idle:
|
||||||
self.last_window_group = None
|
self.idle_start_time = current_time
|
||||||
self.session_start_time = None
|
self.session_start_time = None
|
||||||
else:
|
self.last_window_group = None
|
||||||
self.is_idle = False
|
self.maybe_save_task()
|
||||||
|
logger.debug(f"Переход в бездействие после {current_time - self.last_activity:.0f} сек")
|
||||||
|
return
|
||||||
|
|
||||||
|
# === СЛУЧАЙ 2: Выход из бездействия ===
|
||||||
|
if was_idle and not current_idle:
|
||||||
|
idle_duration = current_time - self.idle_start_time if self.idle_start_time else 0
|
||||||
|
self.idle_start_time = None
|
||||||
|
self.session_start_time = current_time
|
||||||
|
self.last_window_group = group_name
|
||||||
|
|
||||||
|
# Создаём НОВУЮ сессию при длительном простое или смене дня
|
||||||
|
should_create_new = False
|
||||||
|
reasons = []
|
||||||
|
|
||||||
|
if idle_duration > self.config.new_session_after_idle_seconds:
|
||||||
|
should_create_new = True
|
||||||
|
reasons.append(f"простой {idle_duration / 60:.1f} мин")
|
||||||
|
|
||||||
|
if self.current_task.sessions:
|
||||||
|
last_date = self.current_task.sessions[-1].end_time.date()
|
||||||
|
current_date = datetime.now().date()
|
||||||
|
if last_date < current_date:
|
||||||
|
should_create_new = True
|
||||||
|
reasons.append(f"новый день ({last_date} → {current_date})")
|
||||||
|
|
||||||
|
if not self.current_task.sessions:
|
||||||
|
should_create_new = True
|
||||||
|
reasons.append("первая сессия")
|
||||||
|
|
||||||
|
if should_create_new:
|
||||||
|
new_session = WorkSession(
|
||||||
|
start_time=datetime.now(),
|
||||||
|
end_time=datetime.now(),
|
||||||
|
details=[],
|
||||||
|
synchronized=False
|
||||||
|
)
|
||||||
|
self.current_task.sessions.append(new_session)
|
||||||
|
logger.info(
|
||||||
|
f"Новая сессия для {self.current_task.task_id}: {', '.join(reasons)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Добавляем первую запись в новую/текущую сессию
|
||||||
|
if self.current_task.sessions:
|
||||||
|
self._add_work_detail_to_session(self.current_task.sessions[-1], group_name, 5.0)
|
||||||
|
return
|
||||||
|
|
||||||
|
# === СЛУЧАЙ 3: Продолжение активной работы ===
|
||||||
|
if not current_idle:
|
||||||
if self.last_window_group != group_name:
|
if self.last_window_group != group_name:
|
||||||
self.last_window_group = group_name
|
self.last_window_group = group_name
|
||||||
self._add_work_detail(group_name, 5.0)
|
|
||||||
|
|
||||||
# Начинаем/продолжаем сессию
|
# Гарантируем наличие сессии
|
||||||
|
if not self.current_task.sessions:
|
||||||
|
self.current_task.sessions.append(WorkSession(
|
||||||
|
start_time=datetime.now(),
|
||||||
|
end_time=datetime.now(),
|
||||||
|
details=[],
|
||||||
|
synchronized=False
|
||||||
|
))
|
||||||
|
|
||||||
|
self._add_work_detail_to_session(self.current_task.sessions[-1], group_name, 5.0)
|
||||||
if self.session_start_time is None:
|
if self.session_start_time is None:
|
||||||
self.session_start_time = current_time
|
self.session_start_time = current_time
|
||||||
|
return
|
||||||
|
|
||||||
|
def _add_work_detail_to_session(self, session: WorkSession, group_name: str, seconds: float):
|
||||||
|
"""Добавляет запись в существующую сессию и помечает её как изменённую"""
|
||||||
|
# Объединяем записи одной группы
|
||||||
|
for detail in session.details:
|
||||||
|
if detail.group_name == group_name:
|
||||||
|
detail.duration_seconds += seconds
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
session.details.append(WorkDetail(group_name=group_name, duration_seconds=seconds))
|
||||||
|
|
||||||
|
session.end_time = datetime.now()
|
||||||
|
|
||||||
|
# КРИТИЧЕСКИ ВАЖНО: сбрасываем флаг при любом изменении!
|
||||||
|
if session.synchronized:
|
||||||
|
session.synchronized = False
|
||||||
|
logger.debug(
|
||||||
|
f"Сессия {session.id or 'без ID'} помечена как изменённая "
|
||||||
|
f"(было: {session.youtrack_duration_minutes or 0:.1f} мин, "
|
||||||
|
f"стало: {session.total_minutes:.1f} мин)"
|
||||||
|
)
|
||||||
|
|
||||||
# Сохраняем при переходе в бездействие
|
|
||||||
if not was_idle and self.is_idle:
|
|
||||||
self.maybe_save_task()
|
|
||||||
|
|
||||||
def _match_window_to_policy(self, window_info) -> str:
|
def _match_window_to_policy(self, window_info) -> str:
|
||||||
title = window_info.title.lower()
|
title = window_info.title.lower()
|
||||||
@@ -120,7 +208,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 +249,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):
|
||||||
@@ -178,9 +262,17 @@ class Application:
|
|||||||
self.config.tracking_enabled = self.tracking_enabled
|
self.config.tracking_enabled = self.tracking_enabled
|
||||||
self._save_config(self.config)
|
self._save_config(self.config)
|
||||||
|
|
||||||
# Сохраняем при переходе в паузу
|
|
||||||
if was_enabled and not self.tracking_enabled:
|
if was_enabled and not self.tracking_enabled:
|
||||||
|
# Переход в паузу → сохраняем и сбрасываем состояние
|
||||||
self.maybe_save_task()
|
self.maybe_save_task()
|
||||||
|
self.is_idle = True
|
||||||
|
self.idle_start_time = time.time()
|
||||||
|
self.session_start_time = None
|
||||||
|
elif not was_enabled and self.tracking_enabled:
|
||||||
|
# Возобновление → сбрасываем состояние бездействия
|
||||||
|
self.is_idle = False
|
||||||
|
self.idle_start_time = None
|
||||||
|
self.session_start_time = time.time()
|
||||||
|
|
||||||
def create_task(self, task_id: str, project_id: Optional[str], estimated_minutes: Optional[float]):
|
def create_task(self, task_id: str, project_id: Optional[str], estimated_minutes: Optional[float]):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -197,6 +289,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,13 +304,45 @@ 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:
|
||||||
|
logger.warning("Невозможно добавить время: задача не выбрана")
|
||||||
return
|
return
|
||||||
seconds = abs(minutes) * 60
|
|
||||||
sign = 1 if minutes >= 0 else -1
|
if minutes == 0:
|
||||||
self._add_work_detail("Manual", sign * seconds)
|
logger.info("Пропущено добавление 0 минут")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Создаём ОТДЕЛЬНУЮ сессию для ручного времени
|
||||||
|
sign = "+" if minutes > 0 else "-"
|
||||||
|
new_session = WorkSession(
|
||||||
|
start_time=datetime.now(),
|
||||||
|
end_time=datetime.now(),
|
||||||
|
details=[WorkDetail(
|
||||||
|
group_name="Manual Adjustment",
|
||||||
|
duration_seconds=abs(minutes) * 60
|
||||||
|
)],
|
||||||
|
synchronized=False,
|
||||||
|
description=f"Manual: {sign}{abs(minutes):.1f} min"
|
||||||
|
)
|
||||||
|
self.current_task.sessions.append(new_session)
|
||||||
|
logger.info(
|
||||||
|
f"Создана ручная сессия: {minutes:+.1f} мин для {self.current_task.task_id} "
|
||||||
|
f"(всего сессий: {len(self.current_task.sessions)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сохраняем и синхронизируем немедленно
|
||||||
|
self.save_task(self.current_task)
|
||||||
|
try:
|
||||||
|
self.sync_with_youtrack()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка синхронизации после ручного добавления: {e}")
|
||||||
|
|
||||||
def get_display_status(self) -> str:
|
def get_display_status(self) -> str:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -273,9 +401,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 +412,112 @@ class Application:
|
|||||||
self.keyboard_listener.stop()
|
self.keyboard_listener.stop()
|
||||||
|
|
||||||
def sync_with_youtrack(self):
|
def sync_with_youtrack(self):
|
||||||
"""Синхронизирует все несинхронизированные сессии текущей задачи с 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
|
||||||
|
|
||||||
# Проверяем, существует ли задача
|
|
||||||
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
|
||||||
|
|
||||||
changed = False
|
remote_items = client.get_issue_work_items(self.current_task.task_id)
|
||||||
for session in self.current_task.sessions:
|
if not remote_items:
|
||||||
if not session.synchronized:
|
logger.info(f"YouTrack: нет записей времени для задачи {self.current_task.task_id}")
|
||||||
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):
|
|
||||||
session.synchronized = True
|
|
||||||
changed = True
|
|
||||||
else:
|
|
||||||
# Если ошибка — не помечаем как синхронизированную (повторим позже)
|
|
||||||
pass
|
|
||||||
|
|
||||||
if changed:
|
with self._lock:
|
||||||
self.save_task(self.current_task)
|
changed = False
|
||||||
|
|
||||||
|
# Шаг 1: Обрабатываем локальные сессии
|
||||||
|
for session in self.current_task.sessions:
|
||||||
|
total_minutes = session.total_minutes
|
||||||
|
|
||||||
|
if total_minutes <= 0:
|
||||||
|
session.synchronized = True
|
||||||
|
session.youtrack_duration_minutes = 0.0
|
||||||
|
changed = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
# === СЛУЧАЙ A: Сессия с ID — проверяем необходимость обновления ===
|
||||||
|
if session.id:
|
||||||
|
# Проверяем изменение ДАЖЕ если помечена как synchronized
|
||||||
|
needs_update = not session.synchronized or session.is_modified()
|
||||||
|
|
||||||
|
if needs_update:
|
||||||
|
if client.update_work_item(self.current_task.task_id, session.id, total_minutes):
|
||||||
|
session.synchronized = True
|
||||||
|
session.youtrack_duration_minutes = total_minutes
|
||||||
|
changed = True
|
||||||
|
logger.info(
|
||||||
|
f"Обновлена сессия {session.id} для {self.current_task.task_id}: "
|
||||||
|
f"{total_minutes:.1f} мин"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Не удалось обновить сессию {session.id}")
|
||||||
|
# else: сессия не изменилась — пропускаем
|
||||||
|
|
||||||
|
# === СЛУЧАЙ B: Сессия без ID — создаём новую запись ===
|
||||||
|
else:
|
||||||
|
work_item_id = client.add_work_item(
|
||||||
|
self.current_task.task_id,
|
||||||
|
total_minutes,
|
||||||
|
description=session.description or f"Auto: {', '.join(set(d.group_name for d in session.details))}"
|
||||||
|
)
|
||||||
|
if work_item_id:
|
||||||
|
session.id = work_item_id
|
||||||
|
session.synchronized = True
|
||||||
|
session.youtrack_duration_minutes = total_minutes
|
||||||
|
# Пытаемся найти точное совпадение для youtrack_created_at
|
||||||
|
remote_match = next((r for r in remote_items if r['id'] == work_item_id), None)
|
||||||
|
if remote_match and remote_match.get('created'):
|
||||||
|
session.youtrack_created_at = datetime.fromtimestamp(remote_match['created'] / 1000)
|
||||||
|
changed = True
|
||||||
|
logger.info(
|
||||||
|
f"Создана запись {work_item_id} для {self.current_task.task_id}: "
|
||||||
|
f"{total_minutes:.1f} мин"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Не удалось создать запись для сессии без ID")
|
||||||
|
|
||||||
|
# Шаг 2: Добавляем удалённые записи, которых нет локально
|
||||||
|
local_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_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_duration_minutes=remote_item['minutes'],
|
||||||
|
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']} "
|
||||||
|
f"({remote_item['minutes']} мин) в локальную задачу"
|
||||||
|
)
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
self._save_task_no_sync(self.current_task)
|
||||||
|
logger.info(
|
||||||
|
f"Синхронизация завершена для {self.current_task.task_id}: "
|
||||||
|
f"{len(self.current_task.sessions)} сессий"
|
||||||
|
)
|
||||||
|
|
||||||
|
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,7 +14,25 @@ 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
|
||||||
|
youtrack_created_at: Optional[datetime] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
youtrack_duration_minutes: Optional[float] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_encoders = {
|
||||||
|
datetime: lambda v: v.isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_minutes(self) -> float:
|
||||||
|
return sum(d.duration_seconds for d in self.details) / 60.0
|
||||||
|
|
||||||
|
def is_modified(self) -> bool:
|
||||||
|
"""Проверяет, изменилась ли длительность сессии после последней синхронизации"""
|
||||||
|
if self.youtrack_duration_minutes is None:
|
||||||
|
return not self.synchronized # Несинхронизированная сессия = изменённая
|
||||||
|
return abs(self.total_minutes - self.youtrack_duration_minutes) > 0.5 # погрешность 30 сек
|
||||||
|
|
||||||
class TrackedTask(BaseModel):
|
class TrackedTask(BaseModel):
|
||||||
task_id: str
|
task_id: str
|
||||||
@@ -50,6 +68,8 @@ class TrackerConfig(BaseModel):
|
|||||||
tracking_enabled: bool = False
|
tracking_enabled: bool = False
|
||||||
current_task_id: Optional[str] = None
|
current_task_id: Optional[str] = None
|
||||||
window_policies: List[WindowPolicy] = Field(default_factory=list)
|
window_policies: List[WindowPolicy] = Field(default_factory=list)
|
||||||
|
idle_threshold_seconds: int = 60 # Порог перехода в idle
|
||||||
|
new_session_after_idle_seconds: int = 600 # 10 минут для новой сессии
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def default_policies() -> List[WindowPolicy]:
|
def default_policies() -> List[WindowPolicy]:
|
||||||
@@ -72,3 +92,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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 []
|
||||||
Reference in New Issue
Block a user