From a342ede58775c4e09d785371eaa9237f413eca40 Mon Sep 17 00:00:00 2001 From: Moonia <1052578077@qq.com> Date: Mon, 7 Apr 2025 22:32:11 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(db,Manictime):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E4=BD=BF=E7=94=A8=E7=8A=B6=E6=80=81=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E5=88=B0SQLite=EF=BC=8C=E5=B9=B6=E6=8F=90=E4=BE=9BMan?= =?UTF-8?q?icTime=E6=97=B6=E9=97=B4=E7=BA=BFxml=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 ++ .gitignore | 1 + data.py | 192 +++++++++++++++++++++++++++++++++++++ env.py | 3 + flask_ext.pyi | 8 ++ orm.py | 229 ++++++++++++++++++++++++++++++++++++++++++++ server.py | 25 +++++ sql/queryEvents.sql | 0 sql/save.sql | 1 + sql/sqlite_init.sql | 17 ++++ static/favicon.ico | Bin 16958 -> 22119 bytes test.py | 0 utils.py | 24 ++++- 13 files changed, 504 insertions(+), 2 deletions(-) create mode 100644 flask_ext.pyi create mode 100644 orm.py create mode 100644 sql/queryEvents.sql create mode 100644 sql/save.sql create mode 100644 sql/sqlite_init.sql create mode 100644 test.py 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 85cbbc9..b872f79 100644 --- a/data.py +++ b/data.py @@ -1,16 +1,22 @@ # coding: utf-8 +from dataclasses import dataclass import os +import random +from flask import g import pytz import json import threading from time import sleep from datetime import datetime +from orm import AutoORM, ColorGroupIndex, ColorORM, Event +import orm import utils as u import env as env from setting import metrics_list +import sqlite3 class data: ''' @@ -20,6 +26,10 @@ class data: data: dict preload_data: dict data_check_interval: int = 60 + # g.db: sqlite3.Connection + # cursor: sqlite3.Cursor # 这二者亦可拆出来统一给orm管理,这一版暂时选择了留住没动 + # orm: AutoORM + # color_orm: ColorORM def __init__(self): with open(u.get_path('data.template.json'), 'r', encoding='utf-8') as file: @@ -40,6 +50,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 +271,185 @@ 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}') + # self.orm = AutoORM(db_path, db) + # orm.get_color_orm() = ColorORM(self.orm) + + 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.info(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"] + # ds_dict.items() + 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.info(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() + # try: + # self.cursor.execute( + # '''SELECT * FROM ColorGroup WHERE group_name = ?''', + # (ds_dict["app_name"]) + # ) + # if self.cursor.fetchone() == None: + # try: + # self.cursor.execute( + # '''INSERT INTO ColorGroup (group_name, color_hex, set) VALUES (?, ?, ?)''', + # (ds_dict["app_name"], + # f'#{random.randint(0, 0xFFFFFF):06X}', + # 0)) + # self.conn.commit() + # except: + # self.conn.rollback() + # u.warning(f'[save_db] Error while inserting update value into database: {e}') + # except Exception as e: + # u.warning(f'[save_db] Error while updating color table: {e}') + + # @staticmethod + 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: + db_path (str): sqlite数据库的路径 + xml_file (str): 输出xml文件的路径 + ignore_sec (int): 忽略小于等于此时间秒数的事件 + """ + 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: + 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 c447cd3..bee4f37 100644 --- a/env.py +++ b/env.py @@ -79,6 +79,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/flask_ext.pyi b/flask_ext.pyi new file mode 100644 index 0000000..e82b897 --- /dev/null +++ b/flask_ext.pyi @@ -0,0 +1,8 @@ +# flask_ext.pyi +from sqlite3 import Connection +from flask.ctx import _AppCtxGlobals + +class AppCtxGlobals(_AppCtxGlobals): + db: Connection + +def __getattr__(name: str) -> AppCtxGlobals: ... \ No newline at end of file diff --git a/orm.py b/orm.py new file mode 100644 index 0000000..23dbc21 --- /dev/null +++ b/orm.py @@ -0,0 +1,229 @@ +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: + # u.warning('[AutoORM.__init__] Creating connection in orm, which is not expected.') + try: + self.conn = get_db() + except Exception as e: + u.warning('[AutoORM.__init__] ') + else: + self.conn = conn + # self.conn.row_factory = self._auto_mapper_factory + + 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 + # cursor: sqlite3.Cursor + + def __init__(self) -> None: + self.orm = get_orm() + self.cursor = self.orm.conn.cursor() + # 获取 GroupId 最大值 + # result = self.orm.query(ColorGroup,"SELECT MAX(GroupId) FROM colors") + # result = self.cursor.fetchone() + # self.max_GroupId_value = result[0] if result[0] is not None else 1 + + 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() + # self.cursor.execute("SELECT MAX(id) FROM ColorGroup") + # result = self.cursor.fetchone() + # self.max_GroupId_value = result[0] if result[0] is not None else 1 + + 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() + + +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() + +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) + return g.db \ No newline at end of file diff --git a/server.py b/server.py index 0343939..29cf81f 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__) @@ -297,6 +304,8 @@ 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: + d.save_db(device_id) return u.format_dict({ 'success': True, 'code': 'OK' @@ -440,6 +449,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 a86c0b82c8b1b09451f7e10e338cae60f1b48356..9988293bd2fa45ed0ca0842025d209b3ddad2a73 100644 GIT binary patch literal 22119 zcmeGE`9IX}_XmzY=3&U#4WTGwC;PrLBH2R7z6`Qu-zCcoDHXD3Uy4Y!kgS;^JCT&^ z5m~cm=kplP=j-))f4_gh=ZDYj_PE_Fb3LweE$2GtI`?xw^GIL!IwjdfG5`Q5H8s=> z004x31p!hL=*M=@*8}JW%-7)hRiM0|Wf1^SfTo&?QIPd&HX`lPwfd8(wO&ru5v~=a z8Qk*u1Fm?TTsXz+x@nTS%3>5OX($QXIedEl!q?>pOyXiv-c8z@XjpwYHcw4M_cNS5 zy2_HXc%}3GLqz)4@uK(zjjM^W+QPT2Je8^SJ|>lT?uSYad77A*Ac7JAMEuz!a~+-4 zlZHt8&)--O8;ON$oINa@XxKDnFkS55w)%h<#XoPwVW}AvX+(B0OY3+`qpe zz2X64Y~(ekZca3K_Q3F>|NaJ2A@cyQ$_1VDe?{;DG9>>9)4;2M^Z*q@Hs627LJV~L zXH+s8SP=i=xwykW!k|v3na;#Qf1g9>12U$5FLnMA26e(h^snS;!nn>o=}*@DcS<1E z{{NfY|GRe%BLh>cuaZ<}pnABJ4^Y9^Nr0!|o^;-`nff{3xd=Yt!t@6LQRl!-7bAa9 z(sqCe@KS~cfo_;rE}*sVbuS&FbBsmY1AAb3+~QK}x1UD`k~{KOl8}wQC>0 z!~>IM!WXx@bWw9IWQ_*39Na5reE`TUuWj7vaj4inBr*yUn(Mcb7|gYTRN9Uk4Wkj) z&)~qRlRBGBaXRlX0oTj2Mm-A-m&eF-6EAy)xvGDgw9rnu9K9zx5=MqE)Zc%VEYB}G z@&bWG;kZfl0SQ>Fl}6rqP_DU(WAqoCSBZG~M%jqD>r}}fPTwcH?Jj}qDff@~bTj$g zC(eD)6UkG4EI$voE7U}Go`;cPEmM-Z(sa<;PugGT%UpWQK^M2>gJ?1hEoK!WUZ1}| z`<`Z?LQB7i$5vTF599=(qu+{!Bf0YmyRWl}5#fK`w`z8@E;*<1xq#SvZ^$5s1a1@C zY8)!H4N`QTb}9%XFEv3EMY5w|@ie9xT(H%pV*#U%5_I*2r4g z-M4p&x=xfoVM7e({U?9i4iDgfAx){F(@8~*j84aQM}*;g;#Vv{jGnyG9U{h&C+;~y#jhU1_?-2#K=kk> zWw=7~6ZqPyC->!S;~zxsKHB1;bPLrK@xFs^e!lyar9>%R*M7KP3y%aRc4R zSTnmpsT;L$0g#Ft3)B7!w@JFZ#vePXUyNUCM&Ku7|K6xO4hOOxUBA4SZy8`zbBGcq z>A^yXBEGtmeSg%HA&zZJ|=Gbs~wR<}{=etI((b$zgYWX6!g1LpX zhJ~{e&~ebl{whw>;Zg*HwPeWn*C_$yZo=+is_jrcKEQ%)T1V@e4_}8UN-suIb z{#r)*sf!B}mii)OkOxpAQard(_Le(4JTxnIw6V>c>r2786@`=96y_cJ)H9iR?yO0h z_rr&W(%hkUNT6|gamJ&w;djpcbQ|3ew*iVN`k#1k8X|@vVUp7PrF_sI5?XMe!oKVQC!;UaFD`kenpyjaO)U!v?p&rC zv|&E)E&hS`1Th_I@b{!5{m+UjSWW}^hQcqs)qLR6!$I~Ivm5?WZ<40TzszP=J30rC zmNDm_Muh_-lWmom(TmqKb~F~#{T%2*e?-BvP>`omOHaQo4zHmPV~u##w=G%s+1I&4 zSFGor&oI;5*PHIeSsq^FeNo(-4vULNx$|<>jEG;EkS4uf?pet|<%LbXMDIY}LTYg0 zNa%|4GoRl)55TfjjLbmJlzYJW(S$l%i;#HjX<$?7xvt&EPaRp#O(bDOq1R&gh>5x~u&E60)O>34 zJ*!#%0^7NyxW<7vB8Svn4Er~iWVisd@EEfXOdewJnwlV|PJAw`iSGh+%^gzQy_>_w z4jvoUsx!7sobeZ+L*6?u^o9>!^V(C|Su%KYYBV{G>3Rg5p4zSrA13DNvt18qt5+)I zk*f=xW@uqMhm%xBo`6V(S{C~{D}BjfPnKq&Y>H$O7w#6Mm|ik6+UO$Ti%+iFvSFoV z$Um&Mu@co7M$9OB*kk^d6TJ7^&A8uX{~YU3+0niBjmJ{iCljWkA}alvM@B;oBMEQ; zE@%QSKbZoy;C=@)gYh_KLHWLl{rf+BWx(bI*7}xS*3(Td%KbIYcpm5^-Ywzwy^xC4 zwW?y1J5WWpQ;wxkjG# z_^-ZNSP)edV-#YD&Pj)00$SZ_2m{Tb55Kaq$#&s zv#se+c~+tKR9%@**4c5KqzVxME9})x4w_6wH;@{Hvo+ebC@cz^lX>DWPuo}qL=e3F z301qq5s(CE5S^;$(}`+cZ+aJ7UZv-Z7s1UrTf9l?om~5Q|9C(F!Kn+)#JkNeUIMoI zM`(IO_0I#$vx3k;B?Njyk?&qE2Lf4{4K2p%`P)bO$_i9?z32$I0edBjSY_t6&U<05 za6eV>5hOTi|Hup@zc7wgI}mU@5JiVEQ-h`2sKNF>zw} zp-wFLJL+DfoOWEDyZRi3TOp-y*AUsjtsD)BBP8^&VBXz;m2^?q4u3ONr&oreAut^~ z?fPXl^pgX;cfuMv2!FrzFLn;E-ILdEo^T=6nS_H+F@C2^i#b&v?_-#V5E56RPH4^D zTq$6G_C0m@m`H=vfpfTQ$gZrEW)9taJ@t-HKQ_Q_Y`}#^XObTLt~q_lSxY1)621iK zzrHYEt@s0Mz|MjGswg*#CrG8fEd-O*}FHMy!;Jo}08((Zp-&a`oMI=kR(7pEPkN&)8_tgo22^28(BJ z52W3QYn1O;O;e0Wg6}k@SLe*X^nTZcmLT9;Uw!fL+A(Qfi=U7;w;L-if#Y4GZ-=sn z&2$XZm>wv^d0?%V>@&6t!_1D@h@IoGLtgxww8m?1S9 zyE2byk}In^H~Nd=oS{>90Mhiq;CDBHiHTeCtxjM6^^*+0UxfuBv1?{oplV<&cE$>F zq<_pud%p`2<}uvU>$=f5+j&vG{gEqmdU_h#<>-f~YJm>YB z<-17wqWTQQt=fMKlaHL%PU|zkPlgiLHKsm&5z(Z2wtUy1$?)_x5&tWeAl~mTQ`SMsGLU{1$%$dZRcK9TyV#CX z{Gf#vBaVLbo5V1+CrM>d*@n2sGlZ8g@a(mx>K}SQ%Bbrn(_8vU*oQF|b^KR%u7A8v z?Ss#CThNI`ol4fqsqVCfgCjqdIGLI<;qVsdc1YyG{khZ~x^rMuQ&{3|9>BBelz$nQ1Uwsw5>?pSmp)#fh<8 zQ9hD7JFQND+tQtr-myDi2r@Lo=?dKa15CGN@$t^bHV z*F$?F2E0sMxi*d zRkVcF8Zj;MkXFRs6|vyS$s0B>x_%+(4|HOFy>AQPDYp7aoiFp@j{sBi3;26sTwv9U zdU1DhNVKUw=QGu>hU zNh5nRST20Q`LE6x2z|~#-!(_`_O&OnreVN=q<6vqypmKfZuUkZ_`xawEoIM>Qo?QK z>X;NSz0_;>G{fOoR26L=Gf@>STv+uDMFbcJIjHI-*78!RYlP5q{h+i*GmF2@Da zlbSA3F)5@eKkg(Uv*&1IP~EuQTCO^f$4FAni8@0QQbIJSZy~zh?c{lQwUG2C zHGUEZ@c3lC9Qu};cbrs(n0TDGTAm|6eyK1l=sb3}U@5s@BTo`E(Vi|m*)P#;^tPy} zSh)Y34zC6r0wZ~Ejm(#hfjnu|LdKH(Uct$MM9F}-6i7;`472o0t0yn>uAIAiTbU@# zbXjv+srcj~uO-)9|3Z7#USZ*+INNA;9fuqWw?`Us3ta2n>n$5gObwo*9NY!>0PMS$ zKdZS|AAh152R`_JnYEfo6b$fq1of@GcX6%MihSz+ou9`vEM%Q9K+0qMv8dv6f8y}_ zoohEuH!RYdr&=}DUJe~nspkjZK-Rh>!I5lvK~IyLl{sxK^bqgxJqOmLlhmF0fRx@x zJuRtcu)52SUk@5Lo6qYs@N>2J5kb}<%2wZ^v-ZQLecoPgx63holj96~#{A(0_0G@G z<^8rL?E>FV-cs~-&KsI%H%8fTaD;$nSW$k%V+PRsgD;5v~E4cfx8ma^Jc zUhQ*7bR7BI_{LLa28%x*^8t;@f+Z6wm=_Cbkh;v`*>qs>60w2MiCA3QF!d>jsgQ9S zBtdX$p*KwU?%iwu`QqQU1|v}yya+t*O{CYmLkE~4K*D$;-r zu-j$7SrBC64Vg<>vr+!>oX@1;I`iY+V27mVYZ4{%!_+T4AM=%4Uf>Iu-!Fw*s~-9; zeC1+(K0B9=Rj($o7)cLUx@kj2-=B<`x%BE@e}XC*mix)8p^sLB4=MneT1~q0z3rejIT2Pqsty}+>ZLJWL~4Rye_$afO#e&el?ED8JE9@TmY59<5a!d)iQumdk~ zF(Jd}fa&!ee{}~|qd8<)PwtPW9R^0PSrhA%JZvN*My0=G`l<0ebmmE{NS$RC{lwF|V%ncZzn3cDD= zk=H=(1X~d**cp7S`%q)MP|NT3YWug}v({jE2fk-5>F(jwn)MPk3H%2_V3RZR;Aec2 zlbLm1+?A&Y5zuu*SEr2~Jdn|sK@2#r(g@!}C{TQYK^NE0lk^!+#${o7VN8Pk`+|BY zt{!-%k!4!l#*3!srLR8LeSWWz`PIBqPf`A67+;{VPwwIsgd7(DMUN`Z?u_-cLg&-# z6VK8G6xw;Lw^-PGn-~1!=$?s#=*D1W@?ln)o42?%&r$>fEU>}Zf}*{ldAYIS9t0$F zZ4j$Y?9Xn~_yF~Si;%{6L28u0smT|3^0BxWNy@z|-IgMKx%& zK7Nze(B;JV?xb1v^BtMtCt>JxT0*jfQxgL`<|tS>zM{!vHrihX3>d8aq_J8a%rXOu zJpV~zJRvnAf7m(x=({<+_eB1@0kE(=*{nZs4NxDx5kdKH#$tL0p7RN-lYBQjaGp~4 z4;(*D6>bW4rrzae*kU_NxOCpf6EQhYU7ufNUX@SGEOKb2=4j9-@s=N4{*%AC1AiVH@wcb79`=nKP~-~XFrJnIGJU=N*@}2H z8T=&j&X{Ss{Le@9Azbqx@4)g5pT(d%HYZJVcgN&KuS)zUZBxOKM@|!Tj!exx%Kja| zyOXm|cIaX8Q``fok&VA!h82{<3Q92@`7W3r^iEGPD3d7s$PG62(*RSEGS@)Q+QK(u z_hjO98^b6@)yF9@DN}Z}sW|T>bOii)=1F-bY%q7nGEkX(e2t8D6ZW zYs?|wcZ<6kW|aba@H3@Cp@PMc!>?Tjd_^@Tcjg?kBIptzN$Pfz27?17-F!Fstw!m3+wJh;JxM!K(BCzgxOd z-Stu`j-{OO7jc{(C#oL|;RVL(a#e}=U9NTAT0WmcIF@yi8gniaC2ow=h(c!#on#?2 z3UB)*ii0moGP{2y&hrIHWSWeZ6{Y5jWym)*eu|xRdY)L zqVq~vCrL4^7J#@eQX?5%)9fih6#wb`#1RKA{s|nyX4YvU`Y*Z3v2EC5Vwaf-V zNrz~u);RBu7ImDuJe=g6^yrgSO;j$NUc7Ln?+u^qGIYQM&~qKwkWub|m=x%a82_tX zc?aI>5A5jMWggMhnSq}h>=#$*fue~9+5;O?ya{ao^p)L%Rsys^!-gwGC`3pt)@-Pu z`H2aAxvOAmE@O&HU+bMhcrC7!7;WO|_-aTL!GxZ6(Xh2yOlP`u!*jx+3@Cnt{W6oy zM)U-5^SpGIL0F}2=a29KGsW!gBU*V7SUWyLS^L2AEx?q8Sb$)nSVvH*>MdzZ zCOm^Xz7U9}I`{Y+u+9#XMwssOosMeVZYFeFKKUZ=fLyu*RUMFlPPKtS;k4p*mag?W z@#04Rn`{*!Xx7Wv^(QnTLw_Sg!jR6RS z1~KdYXc=;RFH_wB95JGbT8+d$=v=2m-)kr3h<$>eLk3-McMx!uuSWv>PGC89y45VQ zFdV57i)N^R$sFgJ-(Bpa2M?GLk69(SGGt~tv(HA^EvYXnLxrwU0gY)i?lGW7VIrf5 zct%8>3I-u375pws{7;lZVWu8Tgb;i!w9v*+Y#9m6Uj>&rujnm7j;a@U{ryIka9;QV;QYKzdLej5w+5f3O4@lCA zgbte0lMSk?Wd42=)cXi-%t3RBa$J%j`b1}G=4-gYZI|x~pkBWIYO-w7A2H#YAolLJ z(71u{pE7xZ@Jd^SPUk2%1$TS_>2%-J^hfcUJ%rJ266VXtz10kR(Ooc)8*DiE`@r~F zT4Z)YJY<&=X<$wJJfzt2dtr&hY{90S2D zG#yeO1|wRYq=JJ^;Sgv?2|m@DcAvdk@`n^_(0L5AWE$-C_6N&^D1JpK$MNoxDhS|y zx50dMNr9bKMcDdRsPFm~7M z)_D?b&>O<5qMvZCLL`Ahl#ieh>-D#lrt<+p{mkGp@mUTdLD5zE@T%WX$0ShOW09Xm zjgWv~qC7sg6*X`f<%KZv0M-KqSiLhL#6PC5pcDx#?XF_)_*2)|+VK80-}h@t=_AIh zaK`gOpt;({m%1(ZWs+@fQlaZ_LlcsDj(WJ++sPou!V-1fhemA|XBSe#Ooy@Odqh0V^Ycb?Tok2sx1d}^@+0~fI^gZZVZrJa%`ubony7cD z2={9Oq;N}!B+~+Hc{qLj%j|K5=7oz~z*p)Pa9SV<5M@ACLJ&N5c+P&ko@3pjlxZ)S zPaJHAmhfH+$-*;V6Ax}R&sNV!4lm;wq~Y@|J&g@G>JC+?m0r$ApVmX^y!f6fu~#fKOt&VC`g7<7FZQl< zTLR7|d$zpgoiV_rByc~NoWs3i`p8U z-eAxHp`hF&P{jxk;+um(0X9Oq6iWV}J+yn;N_o7Vz^!ZYtsG7Mf_blfD2=H4|9?ez zfV*1LtH};zKga>)QeeI6K6;8X-U0$q>IDVqs&nx6$RG=+^e6;4Z49h+|Nl$>tUc|X z=)k~DiY){tv$)^~+_-VVI=Q=~ryavN*TtI8IV~w%$vybV0o1-}N$$nMDWE05U~V!Y z5v80W!PQiYmm3xMFGD>zbKH-u2GK2?@va2%Lc&SaS!jz>=G%Xhi>kXn!eM!4TzV~W zG$YT6m?lKiR_KoLNyi?XO1Avd3sx3Al64b;j*adIqcV?9=DST@+>U?^^ z#XtU({e1yAr=SiquR}5EYQqJZkljkoT!M#u2b>tVCm?Ewh;|0hWaguD{u>(JY@4y|$JksQw;AjE@`W{3 zU(FM^(Y850`BQ{YY>MO3CnYo5y6a%Yr_X-oLN>b(JK(Zd%6nTzxFewx4TBkOSfnys6lOht8o@3iZ`F z6SJ#%p4y}Z$7<5|xy)&p1n+1cbZ_;AM_ZXocQ!puDd=EdEkdK?67sg5)3cNfUHc2- zZDgpN4_#Af7RHdEneLM!@7Zp#7Kw-GNwa{RmzqiM-MCP^T2C1rFNFOCF-wVk18)?n zhvKX5>rGK27Z%+rRb%>+tOx&e_bJDwV{6_c&AzW|sZO`a)VN&X0Nwd(pZz#g%u3kJ z80#fQ)89W}`%Kj7Lq9St|2nY2wMuhFKdbcR>*~^b1!Xpv4kB#0*Nq9%Y=(e<6nQAsyLu!;i4!|_H$G1%! zzG+#zyeY~pMT~RfcKb^sMXmw8poq9D)3&DwjM*c3(C)*+$jCzR&tR$!Q3qp=o+@dd z>eAl}lLEdMHYYT6x!Zb}s!(3}@X4Youci_`LpYuXu*{OAIFL@mQi7OUAv@8q?~{iW zu0dsM+4X|bq5S~UQBNy=0M50zG4FUOoej*}bVM8M^rlg<}rs#Qj;DemSNjIDQzQ>bppK z%&dcwBf|g9jQkLE!CVJkM~&7&`MNqjf=?=O-*$=v7RXx2K3(8O+fqD0Y8;rFs17-r zGSEgdgsZz=3`)Q^kVf|=<@7Id17g>KvY72 zXd-7`V+g1Ag`;;@IX2qFKf{Xg89>+qJM9?d zMM-r)&HFX$2?_q(#a>*W6+3=-OX${Ex#j-0r%7|wqrZ-brsitz+Ej6+3ft@}>Oa3bRM!W8E(~jE z-+TgOx~9Dw*NOzhTv(a7#xnkq@B&HxfjW4l1q3Ze-xQ^TrSIYgx zHd9dHsAJ>{6T=7{G7*QwlB`_ltC)7nyb^fk8*b7xFXjTzS=b zK0cPZsAZSLrnE0F8Zx745Qb9u{WZw#ll>sgO877ZwvlmO!g@8)lWyWhNCtzOnk;`O%WlKqEL-dy~`iF@I_ zZnC70VupZURFTRCGm5c{=GNJ2ItPb=Bb%(N9nupQrRyD z-Q3Q6J~rXlxk*CIcquk&b@K+=J1)6dI4!jx9lDvjVjluAB9u(CZ|j&F9Q(C13zKL2fPGtTXI$s(&&BE@jsRN+$P2DC896FQ|%$&n$To%V9iphkUNr_k6AYt|Y`Z$n$nwu|>jO9fl`O9w1roR%Euiyz7EAaDq02JNVxx! zVqa-Xsda2R^Ir?2#k6+X#Z=Hn|Aj>#cG@xS831+qb6Io6PwkWcusi7m_djSWr6mlc zWtRhc@`a=_CIM~@WuXSfF{B>9sjz(s_7y9n1p5~j@P~N|inW>OVLw@6EF-2FZa2US zPz((|W+2+U6NBFEQ@C3pYUh?Mh-=ZDj_r}U-`+IF1L&I7ksH@cz@}3ajqfI4iQ&&f zf#;uLcO}*HUcYNxV?vr#jcGp5$;331M2c^1#E` zuC}*tb#p@;mbaZru#AWimFW;f6Wt2w&Rbq60zXN)G|;z(#W0Rx#jVdlNnLFFlYU}s zthoKtQc{J72^@h5P~tx47SMO_)&L5n!1cOm$0z+^7igxh;@k6lVHo?S5jlCVq(i%Y zZ*~QAo~(XLh|-SE(67Ft&pDj&NLV?MGlj&|sdW33vTF8q3q(_Ewa;XUO(QXSHzGh` z(3=CRXhHnpgb=~JCn;tXo;v;h`LSR%=Q@)yMbk9`B`Q^=W@hXTVT!zhlNZF+0|Dl@ zU__`U${Vu9`mA>EPb;3%SvcsRsZ;WTZnUe^{F)4g>C?fiQtb16Y_&N@(&35R`0qd~ z-Tct$ddvv>J@D}wg!^0m#nmvJ1@AiOwKVcmE^^)b=G`$xed&kQW1H!hZIT6K-Kk*_so>jk{73wMzgqGcH)q-kj5d zG#7uyqk<5MI^N2S4g6&nnrQ+2DBsqia_r!B)*EyKE=c;%t__X2OTIk79d=Cc)CXRp z3KD&hcv)4SY{;irD}#I6YX^E!Klint!n4IGsH=Xi`|v?+@fChBu_9ac6*PF(>r(Rm zc5Zv|i>HpH(bi7{%R`^~1)5|odidmylVMZXF_%%_!b@y70Xw%FNGM=fxJa%R-Vo+J z*Rsrk|8?Z4(xIL-2XRBwSZ-IpnX>@U#9?d1K%6TB&0zxC4j!b1!mpjsF*2ztG}f+3^GU9hGF^WkRo?mic~;xc%|Or%_;x7k z09_x8YfQHbSYCc^`cb$p^}Wa>8?w@M&XG0INc$Kr2_86}hHs}8K=U#9NL+;?Z!d2} zVCt|#GT52N8q^jYP%@ieJ=AD&?5mkihRi5Q^2ar_S4R3A#;70ZwRj6q-uDeiW6ln$ z`Lss5!c*OJE;dfQeL9N{LN=yNl&Cv@jPsz{6VwdLXuUK6fiQ|XXBXEdNMB~Syy%Q6 zSng~MzVDm%G9YZ+_WCjJG$SD8MnbTVZg9(SrsxX5j#)|KD?99i!-!qAeZjqzO$I@; z?-OPOO^qBQx&B+$)81;1$xDe;ZSC~MHLQ^J5s3d(l9}wgoiJi%@JFGiAn!}eCl^Bd zqWhk0WR_iq@FA9f?VT&IZ~>Y=pf2dzJz#rWsg4m4Mru66`$S*nQv767wERuLv!5KB zL7LWZTh&jXg%tm=_X_okE=kxk=)U(lKIDA-GMk694>d5%!Ik#a$vNaz)HPTo*_~Cg zqN^KLIh<*ud%oWjdJIopjkDSD6SO=h)Z3tnkH zsCTmV)b95uSs+HSpr*!Ip}-4)wOS_b4j?!wp|DMpwfO1R z*kH{!R?TpFo5ZT!vf#|dZ*d?d?eTIEqZ>9>{yp%4vEB*ms(e}WH}mejQ|If=3th6v z^JXmkEba?*R&=$Gsp)Nq+d96uYqwmz<Mc z&rHBCK{uSWmU+>J1k#|-Oc=lGRd%RNX2n<3kDl_j%212XyarCb)~HXP0$BpMz))Am z@r4_(0Zg_(fz(qnb?&xhq}7jc2hh7^*JesHIDu&-aCy&>I9QLkB<{fv-{qJg!((pFg)eZ4+Zvr;@%<=Qj;o8( zgMi{;NwU~-y~@aoFG3V?4`D|jakJ?>y(fF#9t85VectwGaAhVtPnLUsW)`T`{=j8b zpn^I$Ij$7#3JYMIv)#OR>hB$QINULMq?XG*xwp{JqN`y~F>rkx{(ShT<^BxP?e1!B zVPoUPcSNK22t4}k7b?eI;iOPB^U%8)-Mv2R!>IUEcSLTV`BcA2O6RzuOX`mataVr@ zgU!tMC8kd4DA15pfcTddZ+WWE?^`e7b5~jDfeXfF$IP`6wX76_t@%~d@W$IrR94j2 zG0xT53!iI*GL;`banzUzp%i1;a(>AP5it5F*2SJ-XH)>`tJ~(zhr%zW~eSx>> z0g_=8y+(hIrtO)_Xn#xh0JKQJ+ySo4o?;6I>|R}`p&ujSZFiP$c{y{E+H%=oFjgS= z;NAY?dx~g90ro-qKGM<#kGz~PkJ?88{f)$rrLT&R;QK}Gf{>3J*@Agc@2>86noe#o zrYoPMGldQqkwYM&Irf?H_YcP;sjDP>D-erBkk?2$Bzx_S3QRy1)}uen=o)^Gj27bdlXNUCJ|I_KRfxUVzPKMXtsOi7P@V0J2q+Q65Zn4 zd$4#&^5gACgSk0DBOv?!({UB%*9?MM*;(|QR&9DqOdg;>EYKHvEMRfrq5q9+}FpMsZL|YG20%JeWzU-FbXL2N3mf5-dLhKzy{Vq^ zdvQsC;TBb_eI}Km*s0T+3tBfmZ;Nb8)#3g!fyKYSH()TgOtbIVGbF-)Brs`6P20I8 z2lz<3X1!yH&EZk;Ht%tCMu|gFOM1R%-!@lviLb>vw;fr zL>RE7G5&EvY7_PQK$)FU=Puuv$U^f4LcF8jQ>V8|*WwW6e6X4qquNz;j|_WzDFaFf zi7hSN;1)KwiU?r2Qr*P!=;J@+P;$A@4QG0ia^lJ&hx{=*Ay1>Oua7O0e%^U7+?Y2^ zRG_fPCjQZueh1Qp;)jWB?tb%>W43w6BtYXuq=8avr_Y;<-vLhXmz%wk7wq~>o2f_t zdR$%`qiAqDATYz5Z7bvi$IrV5UqTHgWH%)eaBhkTf%;8;?Cm=T!LOf!QG7_9CJw=e z+d~S3pz25AH{UtwlTe^mx9?R?T$Rj8V02*4E(~R25>}Gajtvs4*wQsY)=KJ;0j>N+ zgjx-M1a^XgTGHmH!_IrT3LAhTmddB^`{%BP>V0WriWRQkH^RD8?z{IUf%xeX%CMz( zt+~L8r&roOh2WFLJB*WtNv=xa*57h&39oO{U*>o7=;8x<%P4F3kJ#vWm{{z>RD0~? zrxo;0sbiA`tcmLI!(VgNK7BIyTW9hpeK#qVmNSJR+f5fkklkt@We*3VB_M^g*oA_P9 zd^bfMh%fT5xn4<}XMB2m1@%yCV80`%cTmd`QoR}W3rUx<8*YpH2|QKG|DnX4Br!yW zlb|B;@qV0PX>lP%>Tf8?Kr z;Wp4fS``5YkFmUq%ftOicB}oa(2-H z3KQM^u`Th}d;BL`_@MfgV%WWiey0LxfM0KEH$PO_)*`xUO9~kyYx0$b3nC5n$I@la z{-?1|smeE>DrwKY4Fe%CEO1#xwDLVa(oj2>&4jrlX-3=N?|TRPg}aZ_LDcM>^6@3< zjDz1wrx(6I=h{`3e!Z|Lc_7>{NFDmEBQtEv`C;9khDFyIms_=c7LK3Y5CR=Qd$)q8 zORa6)YBtot0fdji@!l66s_dR|BcyQF2nAvHqV3&_kS0@Hk34w74Mt5;sOGs6s281a z*rhiR2SA_^82_sSi?=;KBO*IJU>lGmFhy=(M{l5V)~0R=rEMAnsMIY(gM2Z%m3bP2 z9}+~wW6j`oD3R)5=u}~?i-a!V;^qcFwpHr5)bl@)1#vf_kd z1qz7i6hL4){fL^*aU#PeyO#e$4Xm;_YXYlA6dzf3XXlE@cZOWM4vV)_^j}J(9DH*` z3uG27q?QwiBzO{#U!Qjc<;#82@c^?$!$FLGeCw;hS{IF?@U>@t*9d$P2HN-v?SZ=w zuE8uWU4cHIgGvjbiUaLIE-OM=GWG^Mrwszg3Lto|Ay8l>yu;r@@@qYLe2|i`sv}y{3=mZ5MmhPijt?*>PCW>f2Zj%U z_&~Cz#RzDa0pPGMf_vvjPSoPY)`R4$QBc_!QG{^e^kbp3X@r4Sl%f00#$O)001{}` zzFt4>4#mc3emHMMsDMUah5&}aOn1Hs`)9w|E*SbkMJ9D(axbc=OML71w?m1Ohyq6@D8yCT3 z@IYF_4}l39byf5L0{pg^qD#-IGeCTUP#BF4LllSTcW1YBc}DzjRENc*!dJfAZ6?&q@T*rnZ7!KpP`&_>Th^;Z!CynJ1{qI62Cp2 zi%2grBj_uXP*K@Uk^Pi8t|2uczy}~_$_TVbpRlX*E~$iBjiXR|5!*q(>k`}=%nSg| zHjCqR9$*ileKmy80Dl2&d4|8&BL3rL8ZR;WYUMCBD@mpMf8MraTW3+Xsji3Ob1O}m zG6hHgs&Drcfre^UQLsRSk%MRY*xl+zAh9b8M8V2KPICdH3{GfV&#()>3yb!w0%4p< zvJh+85~AZlrAdgimxMQwUJ3X@WDu*b=mw|nOF#o#e(;kW_ypG(;v}qeoyN2jqA9Q; zEhtge5a2|c9IxL6n7J?++ytH#M3X;81~J$D^J$xoiE_(rZ2_AT(_TlHIKrG+c=8&d zLfH;)(l;;lU^M)?=;LcixQ~>gnT4`v!ZWc(Q#^`E<$lapn|>GqZ9Q~LCc5z3VgKo1 zoEt9;v#U@hM)zt!RCV&sSO0xA3wwl)=zkTb8Z8-0hBft84+vxL1po#F;d=opfR76+ zOVn*{sq<%Q=?ylZBW7U#Z48xG9iq}`s?9P8hZy!9F%Fg8QhjG@ONT^dN!-kY)iLAC z|0^pS---+xYouc9KY~al1+u(A4t)JVTHhCzvqjRK=0Y^xELV*d4=2Mixm+=t-lYOi z-4UO%}4T5r6+r3yyUUMAI^Y^D=b1 z+3HwdJ|Yg5s?KQhjh3$Rfv~;IgXzgy`XaQ(IDrIf4fg{GKjdPNw2Y{DoVfGP|4|P@LC^pFoV4O0rsY5SA)||C z)YQ*fk-2X=R0KqycUm>XL;n1VW3|L}g5~j`BQU8ihrer=A%lYK`xnv;S^{-zxb**b z3i2*$)OSvmiN=ADg4uq9qxrI6A6EV&pza1*)UbP!8smtP?DO z7YrN7^FK=vo`{$4{M44z3prv~!_q?-)&P9zaz^?b=Rl=#C{F6^BK4%~$*}Of64o5} z^u7Of{S-S>ZSXhjm-S4SBjoA(}jdxr!NZ7S13z$!4w@=$aXg}YCxAc)(2aNkYdJrzXdZ@Tg|ff3X^w{sjd zQB>eFAX?W%o}J1bB1=7@nP$L0!oZH$x# ziWZDWD(J|}jdt&dg<47ge&%aq11|$P&$=W5eMIOvP{9rq?amQUN&lI><6)cC%`Aj! z5{{N0Qo}}s&RmQj4X@jSk{Gn@^aevrB|k)UomN6LT}twsu5yRg{AzZ*9sqcHe)|Vcg)1jsUaNleBXC@V7#%?b70J>8Lb?S1hE9V2vP8(~ zS?~eR-(DHYlxXvS$fvLm*P)|6^^2v>9omx*2g_$g7JfqbV)(>>1Og&5O$n=P>`KD) z$)4Vy{=ywoNlqBmUC3Hpg{+mVCXdh<wTQ&*oI3NCrZfN~i2{jq^K0h>>Q0$SAtu*un39&SFy5JiAmb^3HN+jkg zhx$k=9La@(q9rE-zsidh_aS@72@mKjJ&WNceu@I)T~}OQ&OugFJQp?J|OBceG0whATfgC`g8!~;7ZF94t^d30ZjeCX*L|m zgKrH-UT2YoZYRXP?u9#-Xqp7M0->r{SDw5tf^+@V--LiGzd@fXg9+$%_z5>oT4&eMs-YE|2U?#xC$cH0muKd#^Ug zBWpz&pLSDWwwzt31r2;Or8z6PahaU!f2~*@d((b$dSzqY0bbuNxIWG zSxC9wOfT(*Q4RAvjU5n}<`q^_4oDcsjD2!~WzI9k2HE{;PZbBWw^P_Q@p7Gh58N z;WaHyXwf#k=j(?L0?x9XCVCc7q4x+W1x7mkw_t4FWlBnIEQm2x+mIe8UpL^5i<$a= z+PU&}sQxy7jA3jUN%ket(83_u%aA2ogy_fEVi-kH$x<|Am+VQ%h_V#2WT%Nlwq_J% zXb>f1nc-(&pL6PY5zimb8#w1&*SWvn`@TP$ib5(V3HuG>j7O-pMH|D_^%Hy@QN+h+ z9Ha2jJQ;Hq+~-8w4qCeBIuGw>i?ee~itRjHmP`uakf+Ju7|28=>pxaz(V6yIS+Z=n zQ20S@lh-aaIIRi+5CSaZxO35-9^=Kju5Yw3GbHiHy*h|*q<(iI9f&d5Vw?Mhcd3;N zkLHkXmBld}_pP{1UV<8NM5VtBea)B4wqS5bN4D7i?%V}5OU9aM!@s2=*%i{(NeE_v z=Vld>^9y*M!IN_lJUI>ht3HB|n_bvV?TV>chBsf<2woR_x)!JE%CfG9Bb1#H_Yi+U z(2Io*@Aurt&$sb-`Ia-)6|I&88MDj}Wh+-N6_4*t_FwHIyXU!aa>NfzMzz;ETC$e2 z;iDwC$}kLT>V$*$$Hd4249vim!UN zo4}3}+B7&(v!59Ak(?&s?T z@53*nKXKmr&@O(y2rZe|hFL!5TBGNtEO3?Mx4NwSZeZ%fm6El)v0*3^shV6z*xECR z>uLYcszyEW|6CRTq7Rxk3APA1tcM!8DTMzHu)52U{QYEn__b^qzic>7JA2mQ9vXxY z&OX4l!XxSi#}X8(<%oMhxWS!0*rqcOmz4JAmsq=3$?(lydpP(RLpEkDMQ4{sjz+Hv z*>bz2HXNvsSSkry_X4(xgjt!jvc$kk%GLz*%AAVZSPTtA!P!jP*$pjiL%z z!FfYnS%~+UaoC|H0h>Llp#%15aNDHh8;b#N21F8Q5#ep-9B1~q-ba-o$1ME8@+pY9 z-X-V_VuybcMo-OP1s2hje!`u| zaN`6?m9-r=N5ea*>l1Q74m)w;fb7@Hdq-N@6*<1HGXe*0q; z-|W8%>ra-0T)bs<=IPmyrTvk=jm2i_LL4bhq->&p_fHFkjsIC)?cQx4ZPf33^=xo5}s=m$xH;atu4eQk> z)GOE```ZS3o-mF4d<&w0vDt9b<2OoR@&jRbCHR!qp9~)UmNcoRr^!;$^bP#gCfi@; z+m6Rey#Msj<7(g3_rpFB9Td7pO(^AeKVXk{PPefemMSk!YxC_S!aI<)sRid9Un^6M zB#~d(vu0|LAp~^Lg444NGAzOOW*p~aNX+t%?y%s%Y zt+RUdPqvN@*|h-_W8+U*B4XfVN2yuB$w0#x28x*o8~#^kfT##Z{alVd|u zeKRiM*3xZxAl1t!7ZJs^O~a#uA0ls-)`>?TLET*om&)n@=;=wOPKVEbV4rbz{iDQo zruGJDz_!_R#ibE@PL`Zwu?K@l@4i@A4992h@#(|^lhG;9?s>$3YUnEe6lJ10c%^&9 zkC{({=pc-X>g9Qh*Xa3@^U*+OoXs!l!7$uZ z4je~czkkJNqHk|73s?}u_hU*+2^%XYad85W)g@W8ZKPy6f_9Xi+?j#?J_p z#&qSFP$=Xf7djf+U0492XE-~HTxq?OXJ=0CdTPA@l&Tf8jGjY-ACvjer_@jT_2qc@ z*6BxyTOQ5jqbKAy)wcUS1`!8W_`8_B9CjbD;OG4sLREJ;XVp3Z*#UpB`9(ch$&^=_M44XFosQZXBX2Ns$3O6vC$H#T~^XHwf9f5g)i=! zb?16bqw-c{pE*sa=dVBGZV*j(|AjCXc^{Gg3dEjC`AjV^QV0zbHdHO)3t~@}!D_4G zerBR5LqmO%H!@89Z%i8a7p1q>yp(Wyp8xPKR+>fB3sjD67RTXI0GR9>su2+IKef*z+HGeB#nzsah-g z)Vzd(rTF^Tndc_n(~AZ}ea7q$E2}*eeF6B@FlSBg{H!c*);yxD2*0UxqhPOp_Sl|X z%FMK2E6aQo1GY3{=Ucf)1w%+ol@@ucq`{pTEDG+IZ()DCU(lmVmya+5@hd@RLI*o- zn*=Sw1;bQ(8~$Apaew)hL+7imDTqn(r+{gGKqpBHiq(;f*oiI1!FmpD;%1J9)#1!| zMZviTGUmI|ISXGU$jYBTPg1gQFejV@1clHg`DyK*_=fgBFvgZ5!&MU~dR!J)p_j)8d> zBT0*k4!7H=68u5#xn1cdY6b)tQ5w6TYNtDTfOPoU7i;@Imq@%wkH6ye?SREAjVTaW z$0|C1ILP|5(I|agwovIp2#PjuNA0bJ(1jJN8#Rg09G%JXjdlUv{(MIVUadq{GX^!0 zm+L=ludQ>u(!t8}!#+<|KT$W^L>7xn;~0$Ko(CKMg&rE-JcGPIkKQtI@-JAvsn*4* zJ^FTZL$X3UKp67fr`H{xZ!;Gvdn~vfRz8N(PI?=KB{INVB@wO7B-qp?Ab7V%J|wkK zJbuh{cq@Zq=;l|!PsXDYGO1@^gGg2l*{=x4qt`HcVey$p*bl?*cBBguVF+ zPm-c?Qjjppy{bD0JO>!o+PeC?@OM&_7x&UZ@y!fEFDV!zl{!mBQ`647)N2>|@fo}T zGyswyd*rGP!4YWKU#iYJsB0EHaF+dj2cI>{N_(TKtn2{ksZ}oE%1A$ZDMmX~Jb9K_ z1>(RrP%gEDStF_c&>ERJn&ueq7R}Z)QK;YOKe9dGd6K`>Ma7Z z9J>3RKubKf&~~oD*fSXUi|6=f<#e!F=12J}yrQzB(e`8Tqdssvf~%3m>UZ&uQ^8v^ zgn$^v1BjR=2Jsf3L8cTD+CCST2R*i2Do=dkv*e^{S9f1P&6p({6X1?fj}}l1w_{5V z!)!?}SrHrsCP{br~zbqp98Pd~2V4xDaDC0zx7g@))~EbjlQoB!*PqU(D(Wrua* TnLaFFax7qCc;28~-!^n&N-=bRA+Tow_2T)a~2>3LZAo; z5ChCo<1PHpi_6sUxvz>T+dI47^>0J9zSgJi_j{lF+c%>*+_;8|S1#ky z^{aUK$M1N}Gw=@%MQdlzFQ+bC_-XajnTOYJ-d+9S`|qm$^uzb||M24vzx(&D^Z)$$ ztJklqeRTi+@Ha1C{`~!qKSI7f{rodL{qi|Jxc342hK6zZ+BICcejUf==CE<*EXI#b z@%tH^Ie!){on3IWv&SiZpO6p_M@MI_;Sy3()6vn@gU*3|=$lx=$lL}dmNq!PvVup? z9wR0u9uvnVvADd1ipnaarKh2zrw3oY_zKr=-^N$pyuh_vH;|l`j;Xm>Y@9!X+wb3H zZ13R3i!bpHfBF$z|Ia`C@WsLenFX9!TSItgI2v195gZx; zcTa!RHMJo#y8v@b%gD$ogpYp^;u2GsLv>i$*uebKBBCN<5F8Q$Z4FIycXwmw*3HPu z&B51P_vLF>(9z$APd#T|C zsY%o~)MMuO0*=kk;r_?>xmVn$AAi7~`1RwTe!w69_!7^*c=qCpFP^FYYcY8A^ojbH zUw!rG$4?*QgZuY*ueZ3b7nPj7bd|No*iX$)WAVfZu6Gr8x&Dnar|{(YGuFU0uKNa# zFD>!@&tql%H12)!F-FHHuzu<^^74z(J2;FH=Jn*vEOH8pl&|8FQh0cHV0df{^9u`% z^?B4cwjwh#8&39aSX?{~eJxGAw{;8dee@w7Fh1+2PGM>FBu<{$ATC@+XKxRV%}yaM zEe%`)zGa>e1Ms7Keg8*%|J_Tx{O((c-JhR7fBIKr@X5mmZ*e^@_=@`vK4v}LQF2Yz zEqngpvxhj#dppniefq^SNL^fK4NlF?<23Q@+~rH?9~os1uVZ3*1`CWseqjO3P0bJ$ z8IQ!I6vQQDqO!Ug$7ZI{(A+lX231wnn3$SEE%)-o$}-wJyOETb%FjFp zKW}gB+p`NJqr-U2++I9+0t;&=QQh1C-ylCM@$cD;?+9ak@yZq4W}bb|9JtE-c=_Ge zc=^rO`1b1;`09)2c>eUsi)T-t{Ign^TUZ$G>FdKJ?_pqQh}hMEa~Ch*?8S2!92w)D z&7p&}-rm{CJsrm~+LVLt94=8k;&%QЭ#^5#A&#>)1;@fe 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 diff --git a/test.py b/test.py new file mode 100644 index 0000000..e69de29 diff --git a/utils.py b/utils.py index 5a3ee66..fd4cd43 100644 --- a/utils.py +++ b/utils.py @@ -1,8 +1,11 @@ from datetime import datetime import json -from flask import make_response, Response +import sqlite3 +from flask import g, make_response, Response from pathlib import Path +import os + def info(log): print(f"{datetime.now().strftime('[%Y-%m-%d %H:%M:%S]')} ℹ️ [Info] {log}") @@ -131,4 +134,21 @@ def get_path(path: str) -> Path: # 适配 Vercel 部署 (调整 data.json 路径为可写的 /tmp/) return '/tmp/sleepy_data.json' else: - return str(Path(__file__).parent.joinpath(path)) \ No newline at end of file + return str(Path(__file__).parent.joinpath(path)) + +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()] + From 9366d546c711ce12b8a05f1dc09321acc064d6c7 Mon Sep 17 00:00:00 2001 From: Moonia <1052578077@qq.com> Date: Wed, 9 Apr 2025 10:11:12 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Devents=E4=B8=BA?= =?UTF-8?q?=E7=A9=BA=E6=97=B6last=5Fevent=E6=97=A0=E6=B3=95=E8=AE=BF?= =?UTF-8?q?=E9=97=AE=E5=AF=BC=E8=87=B4=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data.py b/data.py index b872f79..ac74d49 100644 --- a/data.py +++ b/data.py @@ -397,8 +397,8 @@ def db_to_xml(self, device_id:str ,table:str='Events', start_from:datetime=None, 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: - events.insert(0, last_event[0]) + 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) From c4b96466d817c44f788310fdd11699a97d6a9be9 Mon Sep 17 00:00:00 2001 From: Moonia <1052578077@qq.com> Date: Wed, 9 Apr 2025 10:14:50 +0800 Subject: [PATCH 3/4] chore: lazy update --- server.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server.py b/server.py index 29cf81f..3f72682 100644 --- a/server.py +++ b/server.py @@ -297,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, @@ -305,7 +306,9 @@ 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: - d.save_db(device_id) + if last_app_name != app_name: + # 懒更新,可能用户设计了未切换app_name时亦发送请求,此时不存此条数据 + d.save_db(device_id) return u.format_dict({ 'success': True, 'code': 'OK' From 3ddde6ca20a4a76cf06ad2db687fade55f10a7ee Mon Sep 17 00:00:00 2001 From: Moonia <1052578077@qq.com> Date: Wed, 9 Apr 2025 10:39:50 +0800 Subject: [PATCH 4/4] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=E5=86=97?= =?UTF-8?q?=E4=BD=99=EF=BC=9B=E6=8A=8A=E4=B8=80=E4=BA=9Binfo=E6=94=B9?= =?UTF-8?q?=E6=88=90debug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data.py | 48 ++++++++-------------------------------------- flask_ext.pyi | 8 -------- orm.py | 53 +++++++++++++++++++++------------------------------ utils.py | 1 - 4 files changed, 30 insertions(+), 80 deletions(-) delete mode 100644 flask_ext.pyi diff --git a/data.py b/data.py index ac74d49..6fe634a 100644 --- a/data.py +++ b/data.py @@ -1,16 +1,14 @@ # coding: utf-8 -from dataclasses import dataclass import os import random -from flask import g import pytz import json import threading from time import sleep from datetime import datetime -from orm import AutoORM, ColorGroupIndex, ColorORM, Event +from orm import ColorGroupIndex, Event import orm import utils as u import env as env @@ -26,10 +24,6 @@ class data: data: dict preload_data: dict data_check_interval: int = 60 - # g.db: sqlite3.Connection - # cursor: sqlite3.Cursor # 这二者亦可拆出来统一给orm管理,这一版暂时选择了留住没动 - # orm: AutoORM - # color_orm: ColorORM def __init__(self): with open(u.get_path('data.template.json'), 'r', encoding='utf-8') as file: @@ -285,9 +279,6 @@ def init_db(self): cursor = db.cursor() except Exception as e: u.warning(f'Error when connecting sqlite {env.util.sqlite_name}: {e}') - # self.orm = AutoORM(db_path, db) - # orm.get_color_orm() = ColorORM(self.orm) - 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.') @@ -311,10 +302,9 @@ def save_db(self, device_id:str = None): :param id: 选择存储的设备 ''' db_path = f'{env.util.sqlite_name}.db' - u.info(f'[save_db] started, saving data to {env.util.save_to_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"] - # ds_dict.items() 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.') @@ -332,7 +322,7 @@ def save_db(self, device_id:str = None): device_dict.get('using'), datetime.now().strftime('%Y-%m-%d %H:%M:%S'))) db.commit() - u.info(f'[save_db] Successfully saving data to {env.util.save_to_db}.') + 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}') @@ -348,34 +338,17 @@ def save_db(self, device_id:str = None): orm.get_color_orm().append_row(new_color_row) cursor.close() - # try: - # self.cursor.execute( - # '''SELECT * FROM ColorGroup WHERE group_name = ?''', - # (ds_dict["app_name"]) - # ) - # if self.cursor.fetchone() == None: - # try: - # self.cursor.execute( - # '''INSERT INTO ColorGroup (group_name, color_hex, set) VALUES (?, ?, ?)''', - # (ds_dict["app_name"], - # f'#{random.randint(0, 0xFFFFFF):06X}', - # 0)) - # self.conn.commit() - # except: - # self.conn.rollback() - # u.warning(f'[save_db] Error while inserting update value into database: {e}') - # except Exception as e: - # u.warning(f'[save_db] Error while updating color table: {e}') - # @staticmethod 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: - db_path (str): sqlite数据库的路径 - xml_file (str): 输出xml文件的路径 - ignore_sec (int): 忽略小于等于此时间秒数的事件 + 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 @@ -442,14 +415,9 @@ def db_to_xml(self, device_id:str ,table:str='Events', start_from:datetime=None, 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/flask_ext.pyi b/flask_ext.pyi deleted file mode 100644 index e82b897..0000000 --- a/flask_ext.pyi +++ /dev/null @@ -1,8 +0,0 @@ -# flask_ext.pyi -from sqlite3 import Connection -from flask.ctx import _AppCtxGlobals - -class AppCtxGlobals(_AppCtxGlobals): - db: Connection - -def __getattr__(name: str) -> AppCtxGlobals: ... \ No newline at end of file diff --git a/orm.py b/orm.py index 23dbc21..994afb4 100644 --- a/orm.py +++ b/orm.py @@ -62,14 +62,12 @@ class AutoORM: def __init__(self, db_path: str, conn: sqlite3.Connection = None): self.db_path = db_path if conn is None: - # u.warning('[AutoORM.__init__] Creating connection in orm, which is not expected.') try: self.conn = get_db() except Exception as e: u.warning('[AutoORM.__init__] ') else: self.conn = conn - # self.conn.row_factory = self._auto_mapper_factory def _auto_mapper_factory(self, model: Type[T]) -> callable: """动态生成行转换函数(基于模型类型注解)""" @@ -124,15 +122,10 @@ def close(self): class ColorORM: orm: AutoORM max_GroupId_value: int - # cursor: sqlite3.Cursor def __init__(self) -> None: self.orm = get_orm() self.cursor = self.orm.conn.cursor() - # 获取 GroupId 最大值 - # result = self.orm.query(ColorGroup,"SELECT MAX(GroupId) FROM colors") - # result = self.cursor.fetchone() - # self.max_GroupId_value = result[0] if result[0] is not None else 1 def find_group_id(self, group_name: str) -> str | None: """在ColorGroup表中根据组名寻找GroupId,返回字符串,未找到则返回None""" @@ -161,9 +154,6 @@ def append_row(self, row: Union[list, dict]): u.warning(f'[ColorORM] Expected "row" to be a dict, but got {type(row).__name__}.') self.orm.conn.commit() - # self.cursor.execute("SELECT MAX(id) FROM ColorGroup") - # result = self.cursor.fetchone() - # self.max_GroupId_value = result[0] if result[0] is not None else 1 def find_matching_color_groups(self, events: tuple[Event]) -> list[ColorGroup]: """给定一批 Event 数据类实例,从 ColorGroup 表中找出存在匹配 group_name 的记录""" @@ -188,12 +178,31 @@ 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",) @@ -208,22 +217,4 @@ def close(self): else: print(f'events is {events}!') - orm.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) - return g.db \ No newline at end of file + orm.close() \ No newline at end of file diff --git a/utils.py b/utils.py index fd4cd43..8d96ece 100644 --- a/utils.py +++ b/utils.py @@ -1,6 +1,5 @@ from datetime import datetime import json -import sqlite3 from flask import g, make_response, Response from pathlib import Path