import sqlite3 import os import shutil # 数据库放到 Windows 常见的 APPDATA 目录(避免写在项目目录) APP_NAME = "CleanDesktopOrganizer" _SCHEMA_VERSION_KEY = "schema_version" LATEST_SCHEMA_VERSION = 7 _PROJECT_DB_PATH = os.path.join( os.path.dirname(os.path.dirname(__file__)), "data.db" ) _BASE_DIR = os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA") or "" DB_DIR = os.path.join(_BASE_DIR, APP_NAME) if _BASE_DIR else None DB_PATH = os.path.join(DB_DIR, "data.db") if DB_DIR else _PROJECT_DB_PATH def _ensure_db_dir(): if DB_DIR: os.makedirs(DB_DIR, exist_ok=True) def _migrate_old_db_if_needed(): """ 首次升级:把旧版项目目录下的 data.db 自动拷贝到 APPDATA, 避免你之前分组/设置丢失。 """ try: if os.path.isfile(_PROJECT_DB_PATH) and not os.path.isfile(DB_PATH): _ensure_db_dir() shutil.copy2(_PROJECT_DB_PATH, DB_PATH) except Exception: # 迁移失败不影响程序运行(会在新目录重新创建库) pass def get_conn(): _ensure_db_dir() conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row try: conn.execute("PRAGMA foreign_keys=ON") except Exception: pass return conn def _get_schema_version(conn: sqlite3.Connection) -> int: try: row = conn.execute( "SELECT value FROM settings WHERE key=?", (_SCHEMA_VERSION_KEY,), ).fetchone() if not row: return 0 return int(str(row[0]).strip() or "0") except Exception: return 0 def _set_schema_version(conn: sqlite3.Connection, v: int): conn.execute( "INSERT INTO settings (key, value) VALUES (?,?) " "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (_SCHEMA_VERSION_KEY, str(int(v))), ) def _table_has_column(conn: sqlite3.Connection, table: str, col: str) -> bool: try: rows = conn.execute(f"PRAGMA table_info({table})").fetchall() return any((r[1] if isinstance(r, (tuple, list)) else r["name"]) == col for r in rows) except Exception: return False def _apply_migrations(conn: sqlite3.Connection): """ 版本化迁移(schema migration): - 允许从任意旧版本升级到最新 - 每一步尽量幂等,并在事务中执行 """ def m1(): # 基础表 conn.execute( """ CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ) """ ) conn.execute( """ CREATE TABLE IF NOT EXISTS groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, position INTEGER DEFAULT 0, folder_path TEXT DEFAULT '' ) """ ) conn.execute( """ CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL, icon_path TEXT, position INTEGER DEFAULT 0, FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE ) """ ) # 兼容更老的库:补字段 if not _table_has_column(conn, "groups", "folder_path"): conn.execute("ALTER TABLE groups ADD COLUMN folder_path TEXT DEFAULT ''") def m2(): # 桌面文件框 conn.execute( """ CREATE TABLE IF NOT EXISTS file_boxes ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL DEFAULT '文件框', x INTEGER DEFAULT 100, y INTEGER DEFAULT 100, w INTEGER DEFAULT 240, h INTEGER DEFAULT 320, collapsed INTEGER DEFAULT 0, position INTEGER DEFAULT 0, opacity REAL DEFAULT 0.5 ) """ ) conn.execute( """ CREATE TABLE IF NOT EXISTS file_box_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, box_id INTEGER NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL, position INTEGER DEFAULT 0, FOREIGN KEY (box_id) REFERENCES file_boxes(id) ON DELETE CASCADE ) """ ) def m3(): # 给已存在的 file_boxes 表补 opacity 字段 if not _table_has_column(conn, "file_boxes", "opacity"): conn.execute("ALTER TABLE file_boxes ADD COLUMN opacity REAL DEFAULT 0.5") def m4(): # folder_path:不为空时 FileBox 变成文件夹浏览模式,实时显示该目录内容 if not _table_has_column(conn, "file_boxes", "folder_path"): conn.execute("ALTER TABLE file_boxes ADD COLUMN folder_path TEXT DEFAULT ''") def m5(): # view_mode:拖入模式下的视图方式 —— "icon"(默认)或 "list" if not _table_has_column(conn, "file_boxes", "view_mode"): conn.execute("ALTER TABLE file_boxes ADD COLUMN view_mode TEXT DEFAULT 'icon'") def m6(): # 把所有已存在的 file_boxes 的 opacity 刷成 0.5(之前是 1.0) conn.execute("UPDATE file_boxes SET opacity=0.5 WHERE opacity IS NULL OR opacity >= 0.99") def m7(): # group_id:关联主程序分组,非 NULL 时 FileBox 进入"分组联动模式" if not _table_has_column(conn, "file_boxes", "group_id"): conn.execute("ALTER TABLE file_boxes ADD COLUMN group_id INTEGER DEFAULT NULL") migrations = { 1: m1, 2: m2, 3: m3, 4: m4, 5: m5, 6: m6, 7: m7, } cur = _get_schema_version(conn) # 未设置版本但已有表:视为 0,走迁移补齐并写入版本号 while cur < LATEST_SCHEMA_VERSION: nxt = cur + 1 fn = migrations.get(nxt) if fn is None: raise RuntimeError(f"missing migration for version {nxt}") with conn: fn() _set_schema_version(conn, nxt) cur = nxt def init_db(): _migrate_old_db_if_needed() conn = get_conn() # 先确保 settings 表存在,才能读写 schema_version with conn: conn.execute( """ CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ) """ ) _apply_migrations(conn) # 默认分组(数据迁移/初始化) with conn: row = conn.execute("SELECT COUNT(*) FROM groups").fetchone() cnt = int(row[0] if row else 0) if cnt == 0: conn.execute("INSERT INTO groups (name, position) VALUES ('常用程序', 0)") conn.close() # 非结构类迁移(数据清洗/一次性转换) _migrate_item_shortcut_paths_to_targets() def _migrate_item_shortcut_paths_to_targets(): """将旧数据中仍保存为 .lnk 的路径改存为快捷方式目标(若可解析)。仅执行一次。""" conn = get_conn() row = conn.execute( "SELECT value FROM settings WHERE key='items_lnk_targets_migrated'" ).fetchone() conn.close() if row and row["value"] == "1": return from shortcut_target import path_for_storage conn = get_conn() rows = conn.execute("SELECT id, path FROM items").fetchall() for r in rows: iid, p = r["id"], (r["path"] or "") pl = p.lower() if not pl.endswith(".lnk"): continue try: if not os.path.isfile(p): continue except OSError: continue new_p = path_for_storage(p) if new_p != p: conn.execute("UPDATE items SET path=? WHERE id=?", (new_p, iid)) conn.execute( "INSERT INTO settings (key, value) VALUES (?,?) " "ON CONFLICT(key) DO UPDATE SET value=excluded.value", ("items_lnk_targets_migrated", "1"), ) conn.commit() conn.close() # ── Settings ───────────────────────────────────────────── def get_setting(key: str, default: str = "") -> str: conn = get_conn() row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() conn.close() return row["value"] if row else default def set_setting(key: str, value: str): conn = get_conn() conn.execute( "INSERT INTO settings (key, value) VALUES (?,?) " "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value) ) conn.commit() conn.close() # ── Groups ────────────────────────────────────────────── def get_groups(): conn = get_conn() rows = conn.execute("SELECT * FROM groups ORDER BY position").fetchall() conn.close() return [dict(r) for r in rows] def add_group(name, folder_path=""): conn = get_conn() conn.execute("INSERT INTO groups (name, folder_path) VALUES (?,?)", (name, folder_path)) conn.commit() gid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] conn.close() return gid def rename_group(gid, name): conn = get_conn() conn.execute("UPDATE groups SET name=? WHERE id=?", (name, gid)) conn.commit() conn.close() def delete_group(gid): conn = get_conn() conn.execute("DELETE FROM groups WHERE id=?", (gid,)) conn.commit() conn.close() def reorder_groups(id_list): conn = get_conn() for pos, gid in enumerate(id_list): conn.execute("UPDATE groups SET position=? WHERE id=?", (pos, gid)) conn.commit() conn.close() # ── Items ──────────────────────────────────────────────── def get_items(group_id): conn = get_conn() rows = conn.execute( "SELECT * FROM items WHERE group_id=? ORDER BY position", (group_id,) ).fetchall() conn.close() return [dict(r) for r in rows] def add_item(group_id, name, path, icon_path=None): from shortcut_target import path_for_storage path = path_for_storage(path) conn = get_conn() conn.execute( "INSERT INTO items (group_id, name, path, icon_path) VALUES (?,?,?,?)", (group_id, name, path, icon_path), ) conn.commit() iid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] conn.close() return iid def delete_item(item_id): conn = get_conn() conn.execute("DELETE FROM items WHERE id=?", (item_id,)) conn.commit() conn.close() def move_item(item_id, new_group_id, new_position): conn = get_conn() conn.execute( "UPDATE items SET group_id=?, position=? WHERE id=?", (new_group_id, new_position, item_id), ) conn.commit() conn.close() def reorder_items(group_id, id_list): conn = get_conn() for pos, iid in enumerate(id_list): conn.execute("UPDATE items SET position=? WHERE id=?", (pos, iid)) conn.commit() conn.close() def reset_groups_and_items(recreate_default_group: bool = True): """ 清空所有分组与程序(items)。 - 为避免外键级联受 SQLite 外键开关影响,这里显式先删 items 再删 groups。 - 可选地重新创建默认分组「常用程序」,保证界面至少有一个组可操作。 """ conn = get_conn() conn.execute("DELETE FROM items") conn.execute("DELETE FROM groups") if recreate_default_group: conn.execute("INSERT INTO groups (name, position) VALUES ('常用程序', 0)") conn.commit() conn.close() # ── FileBoxes ────────────────────────────────────────── def get_file_boxes(): conn = get_conn() rows = conn.execute("SELECT * FROM file_boxes ORDER BY position").fetchall() conn.close() return [dict(r) for r in rows] def add_file_box(title: str, x: int = 100, y: int = 100, w: int = 240, h: int = 320) -> int: conn = get_conn() row = conn.execute("SELECT COALESCE(MAX(position), -1) FROM file_boxes").fetchone() pos = (row[0] if row else -1) + 1 cur = conn.execute( "INSERT INTO file_boxes (title, x, y, w, h, position) VALUES (?,?,?,?,?,?)", (title, x, y, w, h, pos), ) conn.commit() box_id = cur.lastrowid conn.close() return box_id def update_file_box(box_id: int, **kwargs): """更新 file_boxes 记录,支持 title/x/y/w/h/collapsed/group_id/folder_path/view_mode/opacity。""" if not kwargs: return allowed = ("title", "x", "y", "w", "h", "collapsed", "group_id", "folder_path", "view_mode", "opacity") fields = [k for k in kwargs.keys() if k in allowed] if not fields: return conn = get_conn() sets = ", ".join(f"{f}=?" for f in fields) conn.execute( f"UPDATE file_boxes SET {sets} WHERE id=?", [kwargs[f] for f in fields] + [box_id], ) conn.commit() conn.close() def create_file_box_from_group(group_id: int) -> int | None: """把已有分组变成桌面文件框(group_id 关联)。返回新 file_boxes.id,失败返回 None。""" conn = get_conn() # 检查分组存在 group = conn.execute("SELECT id, name FROM groups WHERE id=?", (group_id,)).fetchone() if not group: conn.close(); return None # 检查是否已有关联的 FileBox(避免重复) existing = conn.execute("SELECT id FROM file_boxes WHERE group_id=?", (group_id,)).fetchone() if existing: conn.close(); return existing["id"] pos_row = conn.execute("SELECT COALESCE(MAX(position), -1) FROM file_boxes").fetchone() pos = (pos_row[0] if pos_row else -1) + 1 cur = conn.execute( "INSERT INTO file_boxes (title, x, y, w, h, position, opacity, group_id) VALUES (?,?,?,?,?,?,?,?)", (group["name"], 150, 150, 260, 340, pos, 0.5, group_id), ) conn.commit() box_id = cur.lastrowid conn.close() return box_id def delete_file_box(box_id: int): conn = get_conn() conn.execute("DELETE FROM file_box_items WHERE box_id=?", (box_id,)) conn.execute("DELETE FROM file_boxes WHERE id=?", (box_id,)) conn.commit() conn.close() def get_file_box_items(box_id: int): conn = get_conn() rows = conn.execute( "SELECT * FROM file_box_items WHERE box_id=? ORDER BY position", (box_id,) ).fetchall() conn.close() return [dict(r) for r in rows] def add_file_box_item(box_id: int, name: str, path: str) -> int: conn = get_conn() row = conn.execute( "SELECT COALESCE(MAX(position), -1) FROM file_box_items WHERE box_id=?", (box_id,) ).fetchone() pos = (row[0] if row else -1) + 1 cur = conn.execute( "INSERT INTO file_box_items (box_id, name, path, position) VALUES (?,?,?,?)", (box_id, name, path, pos), ) conn.commit() item_id = cur.lastrowid conn.close() return item_id def delete_file_box_item(item_id: int): conn = get_conn() conn.execute("DELETE FROM file_box_items WHERE id=?", (item_id,)) conn.commit() conn.close() def update_file_box_item(item_id: int, **kwargs): if not kwargs: return fields = [k for k in kwargs.keys() if k in ("name", "path", "position")] if not fields: return conn = get_conn() sets = ", ".join(f"{f}=?" for f in fields) conn.execute( f"UPDATE file_box_items SET {sets} WHERE id=?", [kwargs[f] for f in fields] + [item_id], ) conn.commit() conn.close()