This commit is contained in:
Mikan
2026-03-09 22:05:48 +03:00
parent c635b0261b
commit 01cf0f87d0
5 changed files with 282 additions and 28 deletions

89
.gitignore vendored Normal file
View File

@@ -0,0 +1,89 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# Pyre type checker
.pyre/
# IDEs
.vscode/
.idea/
*.swp
*.swo
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Icon?
# Configuration and data files generated by the application
config.json
tasks/
*.json

View File

@@ -1,4 +1,6 @@
"""Time Tracker package.""" """Entry point for running the time tracker as a module."""
from .main import main from .main import main
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -51,16 +51,28 @@ except ImportError:
return 0 return 0
class ConfigWrapper:
"""Wrapper to allow attribute access to config dict"""
def __init__(self, config_dict: Dict):
self.__dict__.update(config_dict)
def update(self, new_config: Dict):
"""Update the config and underlying dict"""
self.__dict__.update(new_config)
class WorkTracker: class WorkTracker:
def __init__(self): def __init__(self):
self.config = load_config() raw_config = load_config()
self.config = ConfigWrapper(raw_config)
self.session_manager = SessionManager("tasks") self.session_manager = SessionManager("tasks")
self.active_window = "" self.active_window = ""
self.window_classification = "neutral" self.window_classification = "neutral"
self.last_activity = time.time() self.last_activity = time.time()
self.session_locked = False self.session_locked = False
self.is_tracking = self.config.get("tracking_enabled", True) self.is_tracking = self.config.tracking_enabled
self.current_task_id = self.config.get("current_task_id") self.current_task_id = self.config.current_task_id
# Start monitoring in background # Start monitoring in background
self.start_monitoring() self.start_monitoring()
@@ -119,7 +131,7 @@ class WorkTracker:
def classify_window(self, title: str) -> str: def classify_window(self, title: str) -> str:
"""Classify window as work, distraction, or neutral""" """Classify window as work, distraction, or neutral"""
title_lower = title.lower() title_lower = title.lower()
rules = self.config.get("window_rules", {}) rules = self.config.window_rules # Access through wrapper
# Check distractions first # Check distractions first
for keyword in rules.get("distraction", []): for keyword in rules.get("distraction", []):
@@ -150,7 +162,6 @@ class WorkTracker:
# Check if tracking is enabled # Check if tracking is enabled
if not self.is_tracking: if not self.is_tracking:
return False return False
# Check if session is locked # Check if session is locked
if self.session_locked: if self.session_locked:
return False return False
@@ -186,16 +197,19 @@ class WorkTracker:
def toggle_tracking(self): def toggle_tracking(self):
"""Toggle tracking on/off""" """Toggle tracking on/off"""
self.is_tracking = not self.is_tracking self.is_tracking = not self.is_tracking
self.config["tracking_enabled"] = self.is_tracking self.config.tracking_enabled = self.is_tracking
save_config(self.config) # Update raw config too
raw_config = {key: value for key, value in vars(self.config).items() if not key.startswith('_')}
save_config(raw_config)
def create_task(self, task_id: str): def create_task(self, task_id: str):
"""Create a new task and start tracking it""" """Create a new task and start tracking it"""
project_id = task_id.split('-')[0] if '-' in task_id else task_id project_id = task_id.split('-')[0] if '-' in task_id else task_id
self.session_manager.create_session(task_id, project_id) self.session_manager.create_session(task_id, project_id)
self.current_task_id = task_id self.current_task_id = task_id
self.config["current_task_id"] = task_id self.config.current_task_id = task_id
save_config(self.config) raw_config = {key: value for key, value in vars(self.config).items() if not key.startswith('_')}
save_config(raw_config)
def switch_task(self, task_id: str): def switch_task(self, task_id: str):
"""Switch to an existing task""" """Switch to an existing task"""
@@ -207,8 +221,9 @@ class WorkTracker:
project_id = task_id.split('-')[0] if '-' in task_id else task_id project_id = task_id.split('-')[0] if '-' in task_id else task_id
self.session_manager.create_session(task_id, project_id) self.session_manager.create_session(task_id, project_id)
self.current_task_id = task_id self.current_task_id = task_id
self.config["current_task_id"] = task_id self.config.current_task_id = task_id
save_config(self.config) raw_config = {key: value for key, value in vars(self.config).items() if not key.startswith('_')}
save_config(raw_config)
def get_available_tasks(self) -> List[str]: def get_available_tasks(self) -> List[str]:
"""Get list of all available tasks""" """Get list of all available tasks"""

View File

@@ -18,11 +18,26 @@ def register_session_listener(callback: Callable[[str], None]) -> None:
"""Register a callback for Windows session lock/unlock events""" """Register a callback for Windows session lock/unlock events"""
def session_event_handler(hwnd, msg, wparam, lparam): def session_event_handler(hwnd, msg, wparam, lparam):
if msg == win32con.WM_WTSSESSION_CHANGE: # Check if the constant exists before using it
if wparam == win32con.WTS_SESSION_LOCK: try:
callback("locked") if msg == win32con.WM_WTSSESSION_CHANGE:
elif wparam == win32con.WTS_SESSION_UNLOCK: if wparam == getattr(win32con, 'WTS_SESSION_LOCK', 0x1):
callback("unlocked") callback("locked")
elif wparam == getattr(win32con, 'WTS_SESSION_UNLOCK', 0x2):
callback("unlocked")
except AttributeError:
# Some versions of pywin32 might not have these constants
# Use numeric values as fallback
WM_WTSSESSION_CHANGE = 0x02B1
WTS_SESSION_LOCK = 0x1
WTS_SESSION_UNLOCK = 0x2
if msg == WM_WTSSESSION_CHANGE:
if wparam == WTS_SESSION_LOCK:
callback("locked")
elif wparam == WTS_SESSION_UNLOCK:
callback("unlocked")
return win32gui.DefWindowProc(hwnd, msg, wparam, lparam) return win32gui.DefWindowProc(hwnd, msg, wparam, lparam)
def run_message_loop(): def run_message_loop():
@@ -39,17 +54,17 @@ def register_session_listener(callback: Callable[[str], None]) -> None:
# Corrected CreateWindow call with all required parameters # Corrected CreateWindow call with all required parameters
hwnd = win32gui.CreateWindow( hwnd = win32gui.CreateWindow(
wndclass.lpszClassName, # lpClassName wndclass.lpszClassName, # lpClassName
"Session Watcher", # lpWindowName "Session Watcher", # lpWindowName
0, # dwStyle 0, # dwStyle
0, # x 0, # x
0, # y 0, # y
0, # nWidth 0, # nWidth
0, # nHeight 0, # nHeight
0, # hWndParent 0, # hWndParent
0, # hMenu 0, # hMenu
hinst, # hInstance hinst, # hInstance
None # lParam None # lParam
) )
# Register for session notifications # Register for session notifications

View File

@@ -0,0 +1,133 @@
import json
import os
import tempfile
from unittest.mock import patch, MagicMock
import tkinter as tk
from time_tracker.main import WorkTracker, ConfigWrapper
def test_tracker_initialization():
"""Test that WorkTracker initializes without errors"""
# Create a temporary config file to avoid creating one in the current directory
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
json.dump({
"tracking_enabled": True,
"current_task_id": None,
"window_rules": {
"work": ["PyCharm", "VS Code"],
"distraction": ["YouTube", "Twitter"]
}
}, tmp)
tmp_path = tmp.name
original_config_path = "config.json"
# Temporarily replace the config file
if os.path.exists(original_config_path):
os.rename(original_config_path, f"{original_config_path}.backup")
os.rename(tmp_path, original_config_path)
try:
# Initialize the tracker
tracker = WorkTracker()
# Verify that config is properly loaded as a ConfigWrapper object
assert hasattr(tracker, 'config')
assert isinstance(tracker.config, ConfigWrapper)
assert hasattr(tracker.config, 'tracking_enabled')
assert hasattr(tracker.config, 'current_task_id')
assert hasattr(tracker.config, 'window_rules')
assert tracker.config.tracking_enabled is True
assert tracker.config.current_task_id is None
finally:
# Restore original config file
os.remove(original_config_path)
if os.path.exists(f"{original_config_path}.backup"):
os.rename(f"{original_config_path}.backup", original_config_path)
def test_config_wrapper_functionality():
"""Test that ConfigWrapper provides attribute access to config values"""
config_dict = {
"tracking_enabled": True,
"current_task_id": "TEST-123",
"window_rules": {
"work": ["PyCharm", "VS Code"],
"distraction": ["YouTube", "Twitter"]
}
}
wrapper = ConfigWrapper(config_dict)
# Test attribute access
assert wrapper.tracking_enabled is True
assert wrapper.current_task_id == "TEST-123"
assert "PyCharm" in wrapper.window_rules["work"]
# Test updating config
wrapper.tracking_enabled = False
assert wrapper.tracking_enabled is False
# Note: Direct assignment doesn't update the original dict, but the wrapper still works
def test_tracker_config_attribute_access():
"""Test that config can be accessed as an object with attributes"""
# Create a mock config object that behaves like the real config
class MockConfig:
def __init__(self):
self.tracking_enabled = True
self.current_task_id = "TEST-123"
config = MockConfig()
assert hasattr(config, 'tracking_enabled')
assert config.tracking_enabled is True
assert config.current_task_id == "TEST-123"
def test_overlay_compatible_config():
"""Test that the config structure is compatible with overlay expectations"""
from time_tracker.core.config import load_config
# Create a temporary config file
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
json.dump({
"tracking_enabled": True,
"current_task_id": "TEST-123",
"window_rules": {
"work": ["PyCharm", "VS Code"],
"distraction": ["YouTube", "Twitter"]
}
}, tmp)
tmp_path = tmp.name
original_config_path = "config.json"
# Temporarily replace the config file
if os.path.exists(original_config_path):
os.rename(original_config_path, f"{original_config_path}.backup")
os.rename(tmp_path, original_config_path)
try:
# Load config using the actual function
config = load_config()
# Verify structure
assert isinstance(config, dict)
assert config.get("tracking_enabled") is True
assert config.get("current_task_id") == "TEST-123"
# Create a compatible object that supports attribute access
wrapped_config = ConfigWrapper(config)
assert hasattr(wrapped_config, 'tracking_enabled')
assert wrapped_config.tracking_enabled is True
assert wrapped_config.current_task_id == "TEST-123"
finally:
# Restore original config file
os.remove(original_config_path)
if os.path.exists(f"{original_config_path}.backup"):
os.rename(f"{original_config_path}.backup", original_config_path)