[AI] MVP 1
This commit is contained in:
@@ -5,7 +5,9 @@ from .app import Application
|
||||
def main():
|
||||
try:
|
||||
app = Application()
|
||||
app.run()
|
||||
from .ui.overlay import OverlayWindow
|
||||
overlay = OverlayWindow(app)
|
||||
overlay.run()
|
||||
except RuntimeError as e:
|
||||
print(f"Ошибка: {e}", file=sys.stderr)
|
||||
input("Нажмите Enter для выхода...")
|
||||
|
||||
@@ -1,26 +1,218 @@
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from .models import TrackerConfig, TrackedTask
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from pynput import mouse, keyboard
|
||||
|
||||
from .models import TrackerConfig, TrackedTask, WorkDetail, WorkSession
|
||||
from .utils import get_platform, ensure_dir
|
||||
from .ui.overlay import OverlayWindow
|
||||
|
||||
|
||||
class Application:
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self.config_path = Path("config.json")
|
||||
self.tasks_dir = Path("tasks")
|
||||
self.config: TrackerConfig = self._load_or_create_config()
|
||||
self.current_task: Optional[TrackedTask] = None
|
||||
self.platform = get_platform()
|
||||
|
||||
# Загружаем текущую задачу, если указан ID
|
||||
# Состояние трекинга
|
||||
self.tracking_enabled = self.config.tracking_enabled
|
||||
self.last_activity: float = time.time() # timestamp
|
||||
self.last_window_group: Optional[str] = None
|
||||
self.is_idle: bool = False
|
||||
self._tracking_thread: Optional[threading.Thread] = None
|
||||
self._running = True
|
||||
|
||||
# Загружаем задачу
|
||||
if self.config.current_task_id:
|
||||
self.current_task = self._load_task(self.config.current_task_id)
|
||||
|
||||
# Создаём директорию для задач, если нужно
|
||||
ensure_dir(self.tasks_dir)
|
||||
|
||||
# Запускаем слушатели активности (только если tracking_enabled)
|
||||
self._start_activity_listeners()
|
||||
# Запускаем фоновый трекер
|
||||
self._start_tracking_loop()
|
||||
|
||||
def _start_activity_listeners(self):
|
||||
def on_activity(*_):
|
||||
with self._lock:
|
||||
self.last_activity = time.time()
|
||||
self.is_idle = False
|
||||
|
||||
self.mouse_listener = mouse.Listener(on_move=on_activity, on_click=on_activity)
|
||||
self.keyboard_listener = keyboard.Listener(on_press=on_activity)
|
||||
self.mouse_listener.start()
|
||||
self.keyboard_listener.start()
|
||||
|
||||
def _start_tracking_loop(self):
|
||||
def loop():
|
||||
while self._running:
|
||||
time.sleep(5)
|
||||
self._check_and_update_tracking()
|
||||
|
||||
self._tracking_thread = threading.Thread(target=loop, daemon=True)
|
||||
self._tracking_thread.start()
|
||||
|
||||
def _check_and_update_tracking(self):
|
||||
with self._lock:
|
||||
if not self.tracking_enabled or not self.current_task:
|
||||
return
|
||||
|
||||
# Проверка: спит ли система? (упрощённо — если активность > 60 сек назад)
|
||||
current_time = time.time()
|
||||
if current_time - self.last_activity > 60:
|
||||
self.is_idle = True
|
||||
self.last_window_group = None
|
||||
return
|
||||
|
||||
# Получаем текущее окно
|
||||
window_info = self.platform.get_active_window()
|
||||
if not window_info.title and not window_info.process_name:
|
||||
self.is_idle = True
|
||||
return
|
||||
|
||||
# Определяем группу по политике
|
||||
group_name = self._match_window_to_policy(window_info)
|
||||
is_blocked = self._is_group_blocked(group_name)
|
||||
|
||||
if is_blocked:
|
||||
self.is_idle = True
|
||||
self.last_window_group = None
|
||||
return
|
||||
|
||||
# Если рабочая группа — накапливаем время
|
||||
self.is_idle = False
|
||||
if self.last_window_group != group_name:
|
||||
# Новая группа — завершаем предыдущую сессию (если была)
|
||||
self.last_window_group = group_name
|
||||
|
||||
# Добавляем 5 секунд к текущей группе
|
||||
self._add_work_detail(group_name, 5.0)
|
||||
|
||||
def _match_window_to_policy(self, window_info) -> str:
|
||||
title = window_info.title.lower()
|
||||
proc = window_info.process_name.lower()
|
||||
|
||||
for policy in self.config.window_policies:
|
||||
for pattern in policy.window_patterns:
|
||||
if pattern.lower() in title:
|
||||
return policy.group_name
|
||||
for pname in policy.process_names:
|
||||
if pname.lower() == proc:
|
||||
return policy.group_name
|
||||
return "Неизвестное приложение"
|
||||
|
||||
def _is_group_blocked(self, group_name: str) -> bool:
|
||||
for policy in self.config.window_policies:
|
||||
if policy.group_name == group_name:
|
||||
return policy.policy_type == "blocked"
|
||||
return False # по умолчанию — не блокируем
|
||||
|
||||
def _add_work_detail(self, group_name: str, seconds: float):
|
||||
if not self.current_task:
|
||||
return
|
||||
|
||||
# Создаём новую сессию или используем последнюю
|
||||
if not self.current_task.sessions:
|
||||
session = WorkSession(
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
details=[],
|
||||
synchronized=False
|
||||
)
|
||||
self.current_task.sessions.append(session)
|
||||
else:
|
||||
session = self.current_task.sessions[-1]
|
||||
|
||||
# Ищем detail с такой же группой
|
||||
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()
|
||||
|
||||
# === Публичные методы с локом ===
|
||||
|
||||
def toggle_tracking(self):
|
||||
with self._lock:
|
||||
if not self.current_task:
|
||||
self.tracking_enabled = False
|
||||
return
|
||||
|
||||
self.tracking_enabled = not self.tracking_enabled
|
||||
self.config.tracking_enabled = self.tracking_enabled
|
||||
self._save_config(self.config)
|
||||
|
||||
def create_task(self, task_id: str, project_id: Optional[str], estimated_minutes: Optional[float]):
|
||||
with self._lock:
|
||||
ensure_dir(self.tasks_dir)
|
||||
new_task = TrackedTask(
|
||||
task_id=task_id,
|
||||
project_id=project_id,
|
||||
estimated_seconds=estimated_minutes * 60 if estimated_minutes else None,
|
||||
start_time=datetime.now()
|
||||
)
|
||||
self.save_task(new_task)
|
||||
self.current_task = new_task
|
||||
self.config.current_task_id = task_id
|
||||
self.config.tracking_enabled = True
|
||||
self.tracking_enabled = True
|
||||
self._save_config(self.config)
|
||||
|
||||
def select_task(self, task_id: str):
|
||||
with self._lock:
|
||||
task = self._load_task(task_id)
|
||||
if task:
|
||||
self.current_task = task
|
||||
self.config.current_task_id = task_id
|
||||
self.config.tracking_enabled = True
|
||||
self.tracking_enabled = True
|
||||
self._save_config(self.config)
|
||||
|
||||
def add_manual_time(self, minutes: float):
|
||||
with self._lock:
|
||||
if not self.current_task:
|
||||
return
|
||||
seconds = abs(minutes) * 60
|
||||
sign = 1 if minutes >= 0 else -1
|
||||
self._add_work_detail("Manual", sign * seconds)
|
||||
|
||||
def get_display_status(self) -> str:
|
||||
with self._lock:
|
||||
if not self.tracking_enabled:
|
||||
return "Пауза"
|
||||
if not self.current_task:
|
||||
return "Пауза"
|
||||
if self.is_idle:
|
||||
return "Бездействие"
|
||||
return "Активная работа"
|
||||
|
||||
def get_current_window_info(self) -> dict:
|
||||
with self._lock:
|
||||
if not self.tracking_enabled or not self.current_task:
|
||||
return {"group": "Пауза", "detail": "Трекинг остановлен. Нажмите «Пауза» для запуска."}
|
||||
|
||||
if self.is_idle:
|
||||
return {"group": "Бездействие", "detail": "Нет активности более 60 сек или отвлекающее окно"}
|
||||
|
||||
window_info = self.platform.get_active_window()
|
||||
group = self._match_window_to_policy(window_info)
|
||||
return {
|
||||
"group": group,
|
||||
"detail": f"{window_info.title} | {window_info.process_name}"
|
||||
}
|
||||
|
||||
# === Остальные методы (без изменений, но с локом где нужно) ===
|
||||
|
||||
def _load_or_create_config(self) -> TrackerConfig:
|
||||
if self.config_path.exists():
|
||||
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||
@@ -51,8 +243,9 @@ class Application:
|
||||
with open(task_file, "w", encoding="utf-8") as f:
|
||||
f.write(task.model_dump_json(indent=2))
|
||||
|
||||
def run(self):
|
||||
# Пока просто запускаем UI
|
||||
# Позже сюда добавим трекинг в фоне
|
||||
overlay = OverlayWindow()
|
||||
overlay.run()
|
||||
def shutdown(self):
|
||||
self._running = False
|
||||
if hasattr(self, 'mouse_listener'):
|
||||
self.mouse_listener.stop()
|
||||
if hasattr(self, 'keyboard_listener'):
|
||||
self.keyboard_listener.stop()
|
||||
@@ -1,9 +1,18 @@
|
||||
import tkinter as tk
|
||||
from datetime import datetime
|
||||
from tkinter import Toplevel, Label, Entry, Button
|
||||
from typing import Callable, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import Application
|
||||
|
||||
|
||||
class AdjustTimeWindow:
|
||||
def __init__(self, parent):
|
||||
def __init__(self, parent, app: "Application", on_close_callback: Callable):
|
||||
self.parent = parent
|
||||
self.app = app
|
||||
self.on_close_callback = on_close_callback
|
||||
|
||||
self.window = Toplevel(parent)
|
||||
self.window.title("Изменить время")
|
||||
self.window.geometry("250x120")
|
||||
@@ -20,5 +29,39 @@ class AdjustTimeWindow:
|
||||
Button(self.window, text="Применить", command=self.on_apply).pack(pady=5)
|
||||
|
||||
def on_apply(self):
|
||||
# Позже: применить изменение
|
||||
if not self.app.current_task:
|
||||
self.window.destroy()
|
||||
return
|
||||
|
||||
try:
|
||||
minutes = float(self.minutes_entry.get())
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if minutes == 0:
|
||||
self.window.destroy()
|
||||
return
|
||||
|
||||
seconds = abs(minutes * 60)
|
||||
# Создаём новую сессию или добавляем в последнюю?
|
||||
# Для простоты — добавим новую сессию с одним WorkDetail
|
||||
|
||||
from ..models import WorkDetail, WorkSession
|
||||
|
||||
detail = WorkDetail(
|
||||
group_name="Manual",
|
||||
duration_seconds=seconds if minutes > 0 else -seconds # можно хранить отрицательное
|
||||
)
|
||||
|
||||
session = WorkSession(
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
details=[detail],
|
||||
synchronized=False
|
||||
)
|
||||
|
||||
self.app.current_task.sessions.append(session)
|
||||
self.app.save_task(self.app.current_task)
|
||||
|
||||
self.on_close_callback()
|
||||
self.window.destroy()
|
||||
@@ -1,14 +1,23 @@
|
||||
import tkinter as tk
|
||||
from tkinter import Toplevel, Label, Entry, Button, Frame
|
||||
from datetime import datetime
|
||||
from tkinter import Toplevel, Label, Entry, Button
|
||||
from typing import Callable, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import Application
|
||||
|
||||
|
||||
class NewTaskWindow:
|
||||
def __init__(self, parent):
|
||||
def __init__(self, parent, app: "Application", on_close_callback: Callable):
|
||||
self.parent = parent
|
||||
self.app = app
|
||||
self.on_close_callback = on_close_callback
|
||||
|
||||
self.window = Toplevel(parent)
|
||||
self.window.title("Новая задача")
|
||||
self.window.geometry("300x200")
|
||||
self.window.transient(parent)
|
||||
self.window.grab_set() # модальное
|
||||
self.window.grab_set()
|
||||
|
||||
Label(self.window, text="ID задачи:").pack(pady=(10, 0))
|
||||
self.task_id_entry = Entry(self.window)
|
||||
@@ -25,5 +34,36 @@ class NewTaskWindow:
|
||||
Button(self.window, text="Создать", command=self.on_create).pack(pady=10)
|
||||
|
||||
def on_create(self):
|
||||
# Позже: сохранить задачу
|
||||
task_id = self.task_id_entry.get().strip()
|
||||
if not task_id:
|
||||
return
|
||||
|
||||
try:
|
||||
estimate_str = self.estimate_entry.get().strip()
|
||||
estimate_seconds = float(estimate_str) * 60 if estimate_str else None
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
project_id = self.project_id_entry.get().strip() or None
|
||||
|
||||
# Создаём новую задачу
|
||||
from ..models import TrackedTask
|
||||
new_task = TrackedTask(
|
||||
task_id=task_id,
|
||||
project_id=project_id,
|
||||
estimated_seconds=estimate_seconds,
|
||||
start_time=datetime.now()
|
||||
)
|
||||
|
||||
# Сохраняем
|
||||
self.app.save_task(new_task)
|
||||
|
||||
# Обновляем конфиг
|
||||
self.app.config.current_task_id = task_id
|
||||
self.app._save_config(self.app.config)
|
||||
|
||||
# Обновляем текущую задачу в памяти
|
||||
self.app.current_task = new_task
|
||||
|
||||
self.on_close_callback()
|
||||
self.window.destroy()
|
||||
@@ -1,10 +1,14 @@
|
||||
import tkinter as tk
|
||||
from tkinter import Menu, Label, Button, Frame
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from ..app import Application
|
||||
|
||||
|
||||
|
||||
class OverlayWindow:
|
||||
def __init__(self):
|
||||
def __init__(self, app: "Application"):
|
||||
self.app = app
|
||||
self.root = tk.Tk()
|
||||
self.root.title("Citrus Time Tracker")
|
||||
self.root.geometry("320x180")
|
||||
@@ -30,6 +34,8 @@ class OverlayWindow:
|
||||
# Содержимое
|
||||
self._build_ui()
|
||||
|
||||
self._auto_update()
|
||||
|
||||
def center_window(self):
|
||||
self.root.update_idletasks()
|
||||
screen_width = self.root.winfo_screenwidth()
|
||||
@@ -66,27 +72,72 @@ class OverlayWindow:
|
||||
btn_frame = Frame(main_frame)
|
||||
btn_frame.pack(side="bottom", fill="x", pady=(10, 0))
|
||||
|
||||
Button(btn_frame, text="⏸ Пауза", command=self.on_pause).pack(side="left", fill="x", expand=True, padx=(0, 2))
|
||||
Button(btn_frame, text="➕ Новая", command=self.on_new_task).pack(side="left", fill="x", expand=True, padx=2)
|
||||
Button(btn_frame, text="🔍 Выбрать", command=self.on_select_task).pack(side="left", fill="x", expand=True, padx=2)
|
||||
Button(btn_frame, text="✏️ Время", command=self.on_adjust_time).pack(side="left", fill="x", expand=True, padx=(2, 0))
|
||||
self.pause_button = Button(btn_frame, text="⏸ Пауза", command=self.on_pause)
|
||||
self.pause_button.pack(side="left", fill="x", expand=True, padx=(0, 2))
|
||||
self.new_task_btn = Button(btn_frame, text="➕ Новая", command=self.on_new_task)
|
||||
self.new_task_btn.pack(side="left", fill="x", expand=True, padx=2)
|
||||
self.select_task_btn = Button(btn_frame, text="🔍 Выбрать", command=self.on_select_task)
|
||||
self.select_task_btn.pack(side="left", fill="x", expand=True, padx=2)
|
||||
self.adjust_time_btn = Button(btn_frame, text="✏️ Время", command=self.on_adjust_time)
|
||||
self.adjust_time_btn.pack(side="left", fill="x", expand=True, padx=(2, 0))
|
||||
|
||||
self._update_display()
|
||||
|
||||
def _update_display(self):
|
||||
status = self.app.get_display_status()
|
||||
self.status_label.config(text=f"Citrus Time Tracker: {status}")
|
||||
|
||||
task_id = self.app.config.current_task_id or "<Не выбрано>"
|
||||
self.task_label.config(text=f"Задача: {task_id}")
|
||||
|
||||
# Время
|
||||
if self.app.current_task:
|
||||
worked = self.app.current_task.total_worked_seconds / 60
|
||||
est = self.app.current_task.estimated_seconds
|
||||
if est is not None:
|
||||
est_min = est / 60
|
||||
rem = self.app.current_task.remaining_seconds / 60
|
||||
time_info = f"Затрачено: {worked:.1f} мин | Оценка: {est_min:.1f} мин"
|
||||
else:
|
||||
time_info = f"Затрачено: {worked:.1f} мин | Оценка: —"
|
||||
else:
|
||||
time_info = "Задача не выбрана"
|
||||
self.time_info_label.config(text=time_info)
|
||||
|
||||
# Окно
|
||||
win_info = self.app.get_current_window_info()
|
||||
self.window_group_label.config(text=win_info["group"])
|
||||
self.window_detail_label.config(text=win_info["detail"])
|
||||
|
||||
# Активность кнопок
|
||||
has_task = self.app.current_task is not None
|
||||
self.pause_button.config(state="normal" if has_task else "disabled")
|
||||
for btn in [self.new_task_btn, self.select_task_btn, self.adjust_time_btn]:
|
||||
btn.config(state="normal")
|
||||
|
||||
# Но "Добавить время" — только если есть задача
|
||||
self.adjust_time_btn.config(state="normal" if has_task else "disabled")
|
||||
|
||||
def _auto_update(self):
|
||||
self._update_display()
|
||||
self.root.after(2000, self._auto_update) # обновляем каждые 2 сек
|
||||
|
||||
# === Обработчики событий (пока без логики, только открытие окон) ===
|
||||
|
||||
def on_pause(self):
|
||||
pass # будет реализовано позже
|
||||
self.app.toggle_tracking()
|
||||
|
||||
def on_new_task(self):
|
||||
from .new_task_window import NewTaskWindow
|
||||
NewTaskWindow(self.root)
|
||||
NewTaskWindow(self.root, self.app, self._update_display)
|
||||
|
||||
def on_select_task(self):
|
||||
from .select_task_window import SelectTaskWindow
|
||||
SelectTaskWindow(self.root)
|
||||
SelectTaskWindow(self.root, self.app, self._update_display)
|
||||
|
||||
def on_adjust_time(self):
|
||||
from .adjust_time_window import AdjustTimeWindow
|
||||
AdjustTimeWindow(self.root)
|
||||
AdjustTimeWindow(self.root, self.app, self._update_display)
|
||||
|
||||
# === UI-управление ===
|
||||
|
||||
|
||||
@@ -1,32 +1,55 @@
|
||||
import tkinter as tk
|
||||
from tkinter import Toplevel, Listbox, Scrollbar, Button, Frame
|
||||
from pathlib import Path
|
||||
from tkinter import Toplevel, Listbox, Scrollbar, Button
|
||||
from typing import Callable, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import Application
|
||||
|
||||
|
||||
class SelectTaskWindow:
|
||||
def __init__(self, parent):
|
||||
def __init__(self, parent, app: "Application", on_close_callback: Callable):
|
||||
self.parent = parent
|
||||
self.app = app
|
||||
self.on_close_callback = on_close_callback
|
||||
|
||||
self.window = Toplevel(parent)
|
||||
self.window.title("Выбрать задачу")
|
||||
self.window.geometry("300x250")
|
||||
self.window.transient(parent)
|
||||
self.window.grab_set()
|
||||
|
||||
list_frame = Frame(self.window)
|
||||
# Сканируем папку tasks/
|
||||
task_files = list(Path("tasks").glob("*.json")) if Path("tasks").exists() else []
|
||||
self.task_ids = [f.stem for f in task_files]
|
||||
|
||||
list_frame = tk.Frame(self.window)
|
||||
list_frame.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
|
||||
scrollbar = Scrollbar(list_frame)
|
||||
scrollbar.pack(side="right", fill="y")
|
||||
|
||||
self.listbox = Listbox(list_frame, yscrollcommand=scrollbar.set)
|
||||
for tid in self.task_ids:
|
||||
self.listbox.insert("end", tid)
|
||||
self.listbox.pack(side="left", fill="both", expand=True)
|
||||
|
||||
# Заглушка: список задач
|
||||
for i in range(5):
|
||||
self.listbox.insert("end", f"TASK-{i+1} (1 ч 20 мин)")
|
||||
|
||||
scrollbar.config(command=self.listbox.yview)
|
||||
|
||||
Button(self.window, text="Выбрать", command=self.on_select).pack(pady=5)
|
||||
|
||||
def on_select(self):
|
||||
# Позже: выбрать задачу
|
||||
selection = self.listbox.curselection()
|
||||
if not selection:
|
||||
return
|
||||
task_id = self.task_ids[selection[0]]
|
||||
|
||||
# Загружаем задачу
|
||||
task = self.app._load_task(task_id)
|
||||
if task:
|
||||
self.app.current_task = task
|
||||
self.app.config.current_task_id = task_id
|
||||
self.app._save_config(self.app.config)
|
||||
self.on_close_callback()
|
||||
|
||||
self.window.destroy()
|
||||
Reference in New Issue
Block a user