diff --git a/.env.example b/.env.example index 54d3551..b1dd0a6 100644 --- a/.env.example +++ b/.env.example @@ -65,3 +65,9 @@ sleepy_util_steam_enabled = false sleepy_util_steam_key = "" # Steam 用户 ID sleepy_util_steam_ids = "" +# 是否储存使用数据到数据库 (可填写 "sqlite" 或 "mysql", 空串为不储存) +sleepy_util_save_to_db = "" +# sqlite 数据库文件名 +sleepy_util_sqlite_name = "usage" +# 是否开启 ManicTime 时间线读取 (基于数据库) +sleepy_util_manictime_load = false diff --git a/.gitignore b/.gitignore index 9327c7d..4e08e18 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ client/Win_Simple/build/ client/Win_Simple/*.spec .env client/Win_Simple/dist/config.ini +*.db diff --git a/data.py b/data.py index 9de0cdd..4714bbe 100644 --- a/data.py +++ b/data.py @@ -1,16 +1,20 @@ # coding: utf-8 import os +import random import pytz import json import threading from time import sleep from datetime import datetime +from orm import ColorGroupIndex, Event +import orm import utils as u import env as env from setting import metrics_list +import sqlite3 class data: ''' @@ -40,6 +44,8 @@ def __init__(self): self.save() except Exception as e: u.exception(f'Create data.json failed: {e}') + self.init_db() + # --- Storage functions @@ -259,5 +265,159 @@ def timer_check(self): except Exception as e: u.warning(f'[timer_check] Error: {e}, retrying.') + def init_db(self): + ''' + 初始化数据库 + + ''' + if env.util.save_to_db == 'sqlite': + db_path = f'{env.util.sqlite_name}.db' + if not os.path.exists(db_path): + u.info('No existing sqlite database founded, will create one.') + try: + db = sqlite3.connect(db_path) + cursor = db.cursor() + except Exception as e: + u.warning(f'Error when connecting sqlite {env.util.sqlite_name}: {e}') + cursor.execute("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='Events'") + if cursor.fetchone()[0] == 0: + u.info('No existing "Events" table, will create one.') + try: + for command in u.get_sql(r'sql/sqlite_init.sql'): + cursor.execute(command) + db.commit() + except Exception as e: + db.rollback() + u.warning(f'Failed to execute SQL file: {str(e)}') + cursor.close() + db.close() + + + def save_db(self, device_id:str = None): + ''' + 将设备使用数据存储至数据库 + + 支持只存储某个设备的数据 + + :param id: 选择存储的设备 + ''' + db_path = f'{env.util.sqlite_name}.db' + u.debug(f'[save_db] started, saving data to {env.util.save_to_db}.') + if env.util.save_to_db == 'sqlite': + ds_dict:dict = self.data["device_status"] + device_dict:dict = ds_dict.get(device_id) + if device_dict == None: + u.warning(f'[save_db] Status of this device not detected, will not save.') + return + + sql_script = u.get_sql(r'sql/save.sql')[0] + if device_id != None: + db = orm.get_db() + cursor = db.cursor() + try: + cursor.execute(sql_script,( + device_id, + device_dict.get('show_name'), + device_dict.get('app_name'), + device_dict.get('using'), + datetime.now().strftime('%Y-%m-%d %H:%M:%S'))) + db.commit() + u.debug(f'[save_db] Successfully saving data to {env.util.save_to_db}.') + except Exception as e: + db.rollback() + u.warning(f'[save_db] Error while inserting value into database: {e}') + + + if orm.get_color_orm().find_group_color(device_dict["app_name"]) == None: + + new_color_row = { + 'group_name': device_dict["app_name"], + 'color_hex': f'#{random.randint(0, 0xFFFFFF):06X}', + '[set]': 0 + } + + orm.get_color_orm().append_row(new_color_row) + cursor.close() + + def db_to_xml(self, device_id:str ,table:str='Events', start_from:datetime=None, end_to:datetime=None, ignore_sec=2) -> str: + """将数据库中设备使用的信息转换为可被ManicTime接收的xml文件 + + Args: + device_id (str): 设备标识符 + start_from (datetime): 开始时间 + end_to (datetime): 结束时间 + """ + # ignore_sec (int): 忽略小于等于此时间秒数的事件 + # 注:table参数和ignore_sec参数相关逻辑未完成,暂时留置 + import xml.etree.ElementTree as ET + from xml.dom import minidom + + # 定义 XML 结构 + timeline = ET.Element("Timeline") + color = ET.SubElement(timeline, "Color") + color.text = "#bacf9c" # #bacf9c + activities = ET.SubElement(timeline, "Activities") + groups_elem = ET.SubElement(timeline,"Groups") + + events = orm.get_orm().query(Event, + "SELECT * FROM Events WHERE device_id = ? AND start_time BETWEEN ? AND ?", + (device_id, + start_from.strftime('%Y-%m-%d %H:%M:%S'), + end_to.strftime('%Y-%m-%d %H:%M:%S')) + ) + + events = sorted(events, key=lambda e: e.start_time, reverse=False) # False:升序,由旧到新 + + if events: + last_event = orm.get_orm().query(Event, '''SELECT * FROM Events WHERE device_id = ? AND id = ?''', (device_id, events[0].id - 1)) + if last_event is not None: + events.insert(0, last_event[0]) + + color_groups = orm.get_color_orm().find_matching_color_groups(events) + color_groups_index = ColorGroupIndex(color_groups) + + for index, event in enumerate(events): + + color_group = color_groups_index.get_group_by_name(event.app_name) + if color_group is None: + u.warning(f'[db_to_xml] Generating group id: event exists but not found in group, will pass event:{event.app_name}') + continue + + group_id = color_group.id + + activity_elem = ET.SubElement(activities, "Activity") + + group_id_elem = ET.SubElement(activity_elem, "GroupId") + group_id_elem.text = str(group_id) + + display_name_elem = ET.SubElement(activity_elem, "DisplayName") + display_name_elem.text = event.app_name + + start_time_elem = ET.SubElement(activity_elem, "StartTime") + start_time_elem.text = event.start_time.strftime("%Y-%m-%dT%H:%M:%S+08:00") + + end_time_elem = ET.SubElement(activity_elem, "EndTime") + if index == len(events) - 1: + end_time_elem.text = datetime.now().strftime("%Y-%m-%dT%H:%M:%S+08:00") + else: + end_time_elem.text = events[index + 1].start_time.strftime("%Y-%m-%dT%H:%M:%S+08:00") + + for color_group in color_groups: + + group_elem = ET.SubElement(groups_elem, "Group") + + group_id_elem = ET.SubElement(group_elem, "GroupId") + group_id_elem.text = str(color_group.id) + + color_elem = ET.SubElement(group_elem,"Color") + color_elem.text = color_group.color_hex + + display_name_elem_g = ET.SubElement(group_elem, "DisplayName") + display_name_elem_g.text = color_group.group_name + + xmlstr = minidom.parseString(ET.tostring(timeline)).toprettyxml(indent=" ") + + return xmlstr + # --- check device heartbeat # TODO diff --git a/env.py b/env.py index 98fc366..3d37e7b 100644 --- a/env.py +++ b/env.py @@ -78,6 +78,9 @@ class _util: steam_enabled: bool = getenv('sleepy_util_steam_enabled', False, bool) steam_key: str = getenv('sleepy_util_steam_key', '', str) steam_ids: str = getenv('sleepy_util_steam_ids', '', str) + save_to_db: str = getenv('sleepy_util_save_to_db', '', str) + sqlite_name: str = getenv('sleepy_util_sqlite_name', 'usage', str) + manictime_load: str = getenv('sleepy_util_manictime_load', False, bool) main = _main() diff --git a/orm.py b/orm.py new file mode 100644 index 0000000..994afb4 --- /dev/null +++ b/orm.py @@ -0,0 +1,220 @@ +from dataclasses import dataclass, fields +from datetime import datetime +import sqlite3 +from typing import List, Optional, Type, TypeVar, Any, Union, get_type_hints + +from flask import g + +import env +import utils as u + +T = TypeVar('T') + +@dataclass +class Event: + id: int + device_id: str + show_name: str + app_name: str + using: bool + start_time: datetime + +@dataclass +class ColorGroup: + id: int + group_name: str + color_hex: str + set: int + +class ColorGroupIndex: + ''' + ColorGroup 的工具类, 用于生成索引器, 便于根据id查找颜色, 而非操作数据库 + ''' + def __init__(self, groups: List[ColorGroup]): + # 构建索引:id -> group_name + if groups is None: + self.id_to_name = {} + else: + self.id_to_name = {g.id: g.group_name for g in groups} + # 你也可以加 group -> ColorGroup 或其他索引 + if groups is None: + self.name_to_obj = {} + else: + self.name_to_obj = {g.group_name: g for g in groups} + + def get_group_name_by_id(self, id_: int) -> Optional[str]: + return self.id_to_name.get(id_) + + def get_group_by_name(self, group_name: str) -> Optional[ColorGroup]: + return self.name_to_obj.get(group_name) + +class AutoORM: + ''' + 基于类型注解的自动ORM系统, 用于sqlite数据库查询 + + 提供更好的类型提示, 便于开发 + ''' + + db_path: str + conn: sqlite3.Connection + # cursor: sqlite3.Cursor + + def __init__(self, db_path: str, conn: sqlite3.Connection = None): + self.db_path = db_path + if conn is None: + try: + self.conn = get_db() + except Exception as e: + u.warning('[AutoORM.__init__] ') + else: + self.conn = conn + + def _auto_mapper_factory(self, model: Type[T]) -> callable: + """动态生成行转换函数(基于模型类型注解)""" + type_hints = get_type_hints(model) + field_types = {f.name: type_hints[f.name] for f in fields(model)} + + def _mapper(cursor: sqlite3.Cursor, row: tuple) -> T: + # 自动匹配字段名和类型 + row_dict = { + col[0]: self._convert_type(value, field_types[col[0]]) + for col, value in zip(cursor.description, row) + if col[0] in field_types + } + return model(**row_dict) + + return _mapper + + def _convert_type(self, value: Any, target_type: type) -> Any: + """智能类型转换系统""" + if value is None: + return None + if target_type == bool and isinstance(value, int): + return bool(value) # 处理SQLite的0/1转bool + if target_type == datetime and isinstance(value, str): + return datetime.fromisoformat(value) # 自动转换ISO时间字符串 + return target_type(value) + + def query(self, model: Type[T], sql: str, params: tuple = ()) -> List[T]: + """通用查询方法,返回模型实例列表 + + Args: + model(Type[T]): 查询所用表的数据模型 + sql(str): 查询sql + params(tuple): 查询参数 + """ + # 动态切换行工厂 + original_factory = self.conn.row_factory + self.conn.row_factory = self._auto_mapper_factory(model) + + try: + cur = self.conn.cursor() + cur.execute(sql, params) + return cur.fetchall() + except Exception as e: + print(f"Err: {e}") + finally: + self.conn.row_factory = original_factory + + def close(self): + self.conn.close() + +class ColorORM: + orm: AutoORM + max_GroupId_value: int + + def __init__(self) -> None: + self.orm = get_orm() + self.cursor = self.orm.conn.cursor() + + def find_group_id(self, group_name: str) -> str | None: + """在ColorGroup表中根据组名寻找GroupId,返回字符串,未找到则返回None""" + self.cursor.execute("SELECT id FROM ColorGroup WHERE group_name = ?", (group_name,)) + result = self.cursor.fetchone() + return str(result[0]) if result else None + + def find_group_color(self, group_name: str) -> str | None: + """在ColorGroup表中根据组名寻找ColorHex,返回字符串,未找到则返回None""" + self.cursor.execute("SELECT color_hex FROM ColorGroup WHERE group_name = ?", (group_name,)) + result = self.cursor.fetchone() + return str(result[0]) if result else None + + def append_row(self, row: Union[list, dict]): + """向 ColorGroup 表中添加一行数据""" + if isinstance(row, dict): + keys = ', '.join(row.keys()) + placeholders = ', '.join(['?'] * len(row)) + values = tuple(row.values()) + sql = f"INSERT INTO ColorGroup ({keys}) VALUES ({placeholders})" + self.cursor.execute(sql, values) + elif isinstance(row, list): + # 留置 + u.warning('[ColorORM] TypeError: Expected a dict, not a list.') + else: + u.warning(f'[ColorORM] Expected "row" to be a dict, but got {type(row).__name__}.') + + self.orm.conn.commit() + + def find_matching_color_groups(self, events: tuple[Event]) -> list[ColorGroup]: + """给定一批 Event 数据类实例,从 ColorGroup 表中找出存在匹配 group_name 的记录""" + + # 提取去重的 group_name(排除 None) + group_names = list({e.app_name for e in events if hasattr(e, 'app_name') and e.app_name}) + + if not group_names: + return [] + + placeholders = ', '.join(['?'] * len(group_names)) + sql = f""" + SELECT id, group_name, color_hex, [set] + FROM ColorGroup + WHERE group_name IN ({placeholders}) + """ + self.orm.query(ColorGroup,sql, group_names) + return self.orm.query(ColorGroup,sql, group_names) + + + def close(self): + self.cursor.close() + self.orm.conn.close() + + +def get_orm() -> AutoORM: + db_path = f'{env.util.sqlite_name}.db' + if 'orm' not in g: + g.orm = AutoORM(db_path) # AutoORM 内部会调用 get_db() 从 g 中取得连接 + return g.orm + +def get_color_orm() -> ColorORM: + if 'color_orm' not in g: + g.color_orm = ColorORM() # ColorORM 内部会调用 get_db() 从 g 中取得连接 + return g.color_orm + +def get_db() -> sqlite3.Connection: + '''不直接用g.db, 获得更好的类型提示''' + db_path = f'{env.util.sqlite_name}.db' + if "db" not in g: + g.db = sqlite3.connect(db_path) + u.info('[get_db] Database connected.') + return g.db + + +if __name__ == "__main__": + '''测试用例''' + orm = AutoORM('test.db') + + events = orm.query(Event, + "SELECT * FROM Events WHERE device_id = ?", + ("test_device",) + ) + + if events: + for event in events: + print(f""" + {event.device_id} 在 {event.start_time:%Y-%m-%d %H:%M} + 使用 {event.app_name}(状态:{'使用中' if event.using else '未使用'}) + """) + else: + print(f'events is {events}!') + + orm.close() \ No newline at end of file diff --git a/server.py b/server.py index b79da8f..6bd6d97 100644 --- a/server.py +++ b/server.py @@ -1,6 +1,7 @@ #!/usr/bin/python3 # coding: utf-8 +import sqlite3 import time import json5 # import importlib - ready for plugin? @@ -15,6 +16,12 @@ from data import data as data_init from setting import status_list +# app.py +# from . import flask_ext +from flask import g + + + try: # init flask app app = flask.Flask(__name__) @@ -290,6 +297,7 @@ def device_set(): if (not device_using) and env.status.not_using: # 如未在使用且锁定了提示,则替换 app_name = env.status.not_using + last_app_name = devices[device_id]['app_name'] devices[device_id] = { 'show_name': device_show_name, 'using': device_using, @@ -297,6 +305,10 @@ def device_set(): } d.data['last_updated'] = datetime.now(pytz.timezone(env.main.timezone)).strftime('%Y-%m-%d %H:%M:%S') d.check_device_status() + if env.util.save_to_db: + if last_app_name != app_name: + # 懒更新,可能用户设计了未切换app_name时亦发送请求,此时不存此条数据 + d.save_db(device_id) return u.format_dict({ 'success': True, 'code': 'OK' @@ -440,6 +452,22 @@ def steam(): steamids=env.util.steam_ids ) +if env.util.manictime_load: + @app.route('/sampleData/CustomTimeline', methods=['GET']) + def m_timeline(): + ''' + 获取 ManicTime 格式的 xml 时间轴数据 + - Method: **GET** + ''' + from_time_str = flask.request.args.get('FromTime', '') + from_time = datetime.strptime(from_time_str, "%Y-%m-%dT%H:%M:%S") + to_time_str = flask.request.args.get('ToTime', '') + to_time = datetime.strptime(to_time_str, "%Y-%m-%dT%H:%M:%S") + device_id = flask.request.args.get('id', '') + xml_content = d.db_to_xml(device_id, 'Events', start_from=from_time, end_to=to_time) + return flask.Response(xml_content, mimetype='text/xml') + + # --- End if __name__ == '__main__': diff --git a/sql/queryEvents.sql b/sql/queryEvents.sql new file mode 100644 index 0000000..e69de29 diff --git a/sql/save.sql b/sql/save.sql new file mode 100644 index 0000000..2ece384 --- /dev/null +++ b/sql/save.sql @@ -0,0 +1 @@ +INSERT INTO Events (device_id,show_name,app_name,[using],start_time) VALUES (?,?,?,?,?) \ No newline at end of file diff --git a/sql/sqlite_init.sql b/sql/sqlite_init.sql new file mode 100644 index 0000000..0c9b1ad --- /dev/null +++ b/sql/sqlite_init.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS Events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL, + show_name TEXT, + app_name TEXT NOT NULL, + [using] BOOLEAN NOT NULL, + start_time DATETIME NOT NULL +); + +CREATE TABLE IF NOT EXISTS ColorGroup ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_name TEXT NOT NULL, + color_hex TEXT NOT NULL DEFAULT '#a0ad9e', + [set] INTEGER DEFAULT 0 +) + +-- INSERT INTO Events (device_id,show_name,app_name,[using],start_time) VALUES ('test_device','手机','哔哩哔哩',true,'2025-4-5 11:00:00') \ No newline at end of file diff --git a/static/favicon.ico b/static/favicon.ico index a86c0b8..9988293 100644 Binary files a/static/favicon.ico and b/static/favicon.ico differ diff --git a/test.py b/test.py new file mode 100644 index 0000000..e69de29 diff --git a/utils.py b/utils.py index 5ce98cc..fcc9b65 100644 --- a/utils.py +++ b/utils.py @@ -1,8 +1,7 @@ from datetime import datetime import json -from flask import make_response, Response +from flask import g, make_response, Response from pathlib import Path - import os from _utils import * @@ -130,3 +129,20 @@ def list_dir(path: str, include_subfolder: bool = True, strict_exist: bool = Fal return [] else: return filelst + + +def get_sql(path: str) -> list: + ''' + 从sql文件获取按分号分割的sql语句列表 + ''' + if not path.endswith('.sql'): + error('Get_sql func gets a non-sql file. Will return empty list.') + return [] + + if not os.path.exists(path): + error(f"SQL file not exists: {path}. Will return empty list.") + return [] + + with open(path, 'r', encoding='utf-8') as f: + sql_script = f.read() + return [cmd.strip() for cmd in sql_script.split(';') if cmd.strip()] \ No newline at end of file