Files
niumasoftware/db/database.py
T
2026-10-12 14:57:46 +08:00

502 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()