beeref/beeref/fileio/sql.py
2025-10-28 10:50:33 +08:00

376 lines
24 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.

# This file is part of BeeRef.
#
# BeeRef is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# BeeRef is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
"""BeeRef's native file format is using SQLite. Embedded files are
stored in an sqlar table so that they can be extracted using sqlite's
archive command line option.
For more info, see:
https://www.sqlite.org/appfileformat.html
https://www.sqlite.org/sqlar.html
"""
# 导入必要的模块
import json # 导入 JSON 模块,用于处理 JSON 数据
import logging # 导入日志模块,用于记录程序运行时的信息
import os # 导入 os 模块,用于处理文件路径
import pathlib # 导入 pathlib 模块,用于处理文件路径
import shutil # 导入 shutil 模块,用于文件操作
import sqlite3 # 导入 sqlite3 模块,用于操作 SQLite 数据库
import tempfile # 导入 tempfile 模块,用于创建临时文件
from PyQt6 import QtGui # 导入 PyQt6 模块,用于 GUI 编程
from beeref import constants # 导入 BeeRef 模块,用于定义常量
from beeref.items import BeePixmapItem, BeeErrorItem # 导入 BeeRef 模块,用于定义项
from .errors import BeeFileIOError, IMG_LOADING_ERROR_MSG # 导入 BeeRef 模块,用于定义错误
from .schema import SCHEMA, USER_VERSION, MIGRATIONS, APPLICATION_ID # 导入 BeeRef 模块,用于定义数据库模式
# 配置日志记录器
logger = logging.getLogger(__name__)
# 检查文件是否为bee文件
def is_bee_file(path):
"""Check whether the file at the given path is a bee file."""
return os.path.splitext(path)[1] == '.bee' # 使用os.path.splitext分割文件名和扩展名
# 处理SQLite错误
def handle_sqlite_errors(func):
def wrapper(self, *args, **kwargs):
try:
# 尝试执行原始函数
func(self, *args, **kwargs) # 执行函数
except Exception as e: # 捕获所有异常
logger.exception(f'Error while reading/writing {self.filename}')# 记录异常日志,包含文件名信息
try: # 尝试回滚事务
# Try to roll back transaction if there is any
if (hasattr(self, '_connection') # 检查是否有数据库连接属性
and self._connection.in_transaction): # 检查是否有未提交的事务
self.ex('ROLLBACK') # 执行SQL回滚命令
logger.debug('Transaction rolled back') # 记录回滚信息
except sqlite3.Error as e: # 捕获SQLite错误
pass # 如果回滚失败忽略SQLite错误
self._close_connection() # 关闭数据库连接
if self.worker: # 如果有工作线程,则向其发送完成信号并附带错误信息
self.worker.finished.emit(self.filename, [str(e)]) # 发射工作线程完成信号
else: # 如果没有工作线程
raise BeeFileIOError(msg=str(e), filename=self.filename) from e# 抛出 BeeFileIOError 异常
return wrapper # 返回包装后的函数
# 定义SQLiteIO类用于操作SQLite数据库文件
class SQLiteIO:
# 初始化SQLiteIO类
def __init__(self, filename, scene, create_new=False, readonly=False,
worker=None):
self.scene = scene # 保存场景引用,用于添加或获取项目
self.create_new = create_new # 设置是否创建新文件的标志
self.filename = filename # 保存数据库文件名
self.readonly = readonly # 设置是否为只读模式
self.worker = worker # 保存工作线程引用,用于进度更新
self.retry = False # 初始化重试标志,用于错误恢复
# 析构函数
def __del__(self):
self._close_connection() # 在对象被销毁时调用,确保资源被正确释放
# 关闭数据库连接
def _close_connection(self):
# 检查并关闭数据库连接
if hasattr(self, '_connection'): # 检查是否有数据库连接属性
self._connection.close() # 关闭数据库连接
delattr(self, '_connection') # 删除数据库连接属性
# 检查并删除游标引用
if hasattr(self, '_cursor'): # 检查是否有游标属性
delattr(self, '_cursor') # 删除游标属性
# 检查并清理临时目录
if hasattr(self, '_tmpdir'): # 检查是否有临时目录属性
self._tmpdir.cleanup() # 清理临时目录
delattr(self, '_tmpdir') # 删除临时目录属性
# 建立数据库连接
def _establish_connection(self):
# 如果需要创建新文件且文件已存在,则删除现有文件
if (self.create_new # 检查是否创建新文件
and not self.readonly # 检查是否为只读模式
and os.path.exists(self.filename)): # 检查文件是否已存在
os.remove(self.filename) # 删除已存在的文件
# 如果创建新文件清除场景中所有项目的保存I
if self.create_new: # 检查是否创建新文件
self.scene.clear_save_ids() # 清除场景中所有项目的保存ID
uri = pathlib.Path(self.filename).resolve().as_uri()# 将文件路径转换为URI格式确保跨平台兼容性
if self.readonly: # 检查是否为只读模式
uri = f'{uri}?mode=rw' # 如果是只读模式,添加查询参数以指定为读写模式
self._connection = sqlite3.connect(uri, uri=True) # 建立数据库连接使用URI格式
self._cursor = self._connection.cursor() # 创建游标对象用于执行SQL语句
# 如果不是创建新文件,则尝试迁移数据库
if not self.create_new: # 检查是否不是创建新文件
try:
self._migrate() # 尝试迁移数据库
except Exception as e: # 捕获所有异常
# Updating a file failed; try creating it from scratch instead
logger.exception('Error migrating bee file: %s', e) # 记录异常日志,包含异常信息
self.create_new = True # 标记为创建新文件
self._establish_connection() # 重新建立数据库连接
# 迁移数据库
def _migrate(self):
"""Migrate database if necessary."""
version = self.fetchone('PRAGMA user_version')[0] # 获取数据库用户版本号
logger.debug(f'Found bee file version: {version}') # 记录数据库版本号
if version >= USER_VERSION: # 检查数据库版本号是否大于等于用户版本号
logger.debug('Version ok; no migrations necessary') # 如果版本号大于等于用户版本号,无需迁移
return # 如果版本号大于等于用户版本号,无需迁移
# 如果版本已经是最新的,不需要迁移
if self.readonly:
try:
# See whether file is writable so we can migrate it directly
self.ex('PRAGMA application_id=%s' % APPLICATION_ID) # 设置应用程序ID确保数据库与应用程序兼容
except sqlite3.Error as e: # 捕获 SQLite 错误异常
logger.debug('File not writable; use temporary copy instead')# 记录文件不可写日志,建议使用临时副本
self._connection.close() # 关闭数据库连接
self._tmpdir = tempfile.TemporaryDirectory( # 创建临时目录,用于存储迁移后的数据库文件
prefix=constants.APPNAME) # 设置临时目录前缀,确保与应用程序名称一致
tmpname = os.path.join(self._tmpdir.name, 'mig.bee') # 构建临时数据库文件名
shutil.copyfile(self.filename, tmpname) # 复制原始数据库文件到临时目录
self._connection = sqlite3.connect(tmpname) # 连接到临时数据库文件
self._cursor = self.connection.cursor() # 创建游标对象用于执行SQL语句
self.ex('BEGIN TRANSACTION') # 开始事务,确保迁移的原子性
for i in range(version, USER_VERSION): # 遍历需要迁移的版本号范围
logger.debug(f'Migrating from version {i} to {i + 1}...') # 记录迁移信息,包含当前版本号和目标版本号
for migration in MIGRATIONS[i + 1]: # 遍历当前版本号对应的迁移SQL语句列表
self.ex(migration) # 执行迁移SQL语句
self.write_meta() # 写入数据库元数据包括应用程序ID和用户版本号
self.connection.commit() # 提交事务,确保所有迁移操作都被永久保存
logger.debug('Migration finished') # 记录迁移完成日志
@property
# 获取数据库连接属性
def connection(self):
if not hasattr(self, '_connection'): # 检查是否没有数据库连接属性
self._establish_connection() # 如果没有数据库连接属性,建立数据库连接
return self._connection # 返回数据库连接属性
@property
# 获取数据库游标属性
def cursor(self):
if not hasattr(self, '_cursor'): # 检查是否没有数据库游标属性
self._establish_connection() # 如果没有数据库游标属性,建立数据库连接
return self._cursor # 返回数据库游标属性
# 执行SQL语句返回执行结果
def ex(self, *args, **kwargs):
return self.cursor.execute(*args, **kwargs)
# 执行批量SQL语句返回执行结果
def exmany(self, *args, **kwargs):
return self.cursor.executemany(*args, **kwargs)
# 从数据库游标中获取一条记录,返回记录元组
def fetchone(self, *args, **kwargs):
self.ex(*args, **kwargs) # 执行SQL语句传入参数和关键字参数
return self.cursor.fetchone() # 返回查询结果的第一行
# 从数据库游标中获取所有记录,返回记录列表
def fetchall(self, *args, **kwargs):
self.ex(*args, **kwargs) # 执行SQL语句传入参数和关键字参数
return self.cursor.fetchall() # 从游标中获取所有记录,返回记录列表
# 写入数据库元数据包括应用程序ID和用户版本号
def write_meta(self):
self.ex('PRAGMA application_id=%s' % APPLICATION_ID) # 设置应用程序ID确保数据库与应用程序兼容
self.ex('PRAGMA user_version=%s' % USER_VERSION) # 设置用户版本,用于版本控制
self.ex('PRAGMA foreign_keys=ON') # 启用外键约束,确保数据完整性
# 创建新文件时创建数据库架构
def create_schema_on_new(self):
if self.create_new: # 仅在创建新文件时执行
self.write_meta() # 写入数据库元数据
for schema in SCHEMA: # 执行所有架构创建SQL语句
self.ex(schema) # 执行架构创建SQL语句
# 从数据库中读取所有项,包括图片和文本项
@handle_sqlite_errors
def read(self):
# 读取所有图像项目及其关联的二进制数据
rows = self.fetchall(
'SELECT items.id, type, x, y, z, scale, rotation, flip, '
'items.data, sqlar.data '
'FROM sqlar JOIN items on sqlar.item_id = items.id')
# Avoid OUTER JOIN for performance reasons; fetch text items
# separately instead
# 读取所有文本项目
rows.extend(self.fetchall(
'SELECT items.id, type, x, y, z, scale, rotation, flip, '
' items.data, null as data '
'FROM items '
'WHERE items.type = "text"'))
if self.worker: # 如果有工作线程
self.worker.begin_processing.emit(len(rows)) # 发送开始处理信号,包含项数
# 遍历所有读取的项目数据
for i, row in enumerate(rows):
# 构建项目数据字典
data = {
'save_id': row[0], # 项目ID
'type': row[1], # 项目类型
'x': row[2], # X坐标
'y': row[3], # Y坐标
'z': row[4], # Z坐标
'scale': row[5], # 缩放比例
'rotation': row[6], # 旋转角度
'flip': row[7], # 是否翻转
'data': json.loads(row[8]), # 项目数据
}
# 处理像素图项目
if data['type'] == 'pixmap': # 如果项目类型为像素图
item = BeePixmapItem(QtGui.QImage()) # 创建空的像素图项目
item.pixmap_from_bytes(row[9]) # 从二进制数据创建像素图
# 检查图像是否加载成功
if item.pixmap().isNull():
# 如果图像加载失败,创建错误项目
item = data['data']['text'] = (
f'Image could not be loaded: {item.filename}\n'
+ IMG_LOADING_ERROR_MSG)
data['type'] = BeeErrorItem.TYPE # 将项目类型设置为错误项类型
data['item'] = item # 将项目添加到数据字典中
self.scene.add_item_later(data) # 稍后将项目添加到场景中
# 如果有工作线程,更新进度并检查是否取消操作
if self.worker:
logger.trace(f'Emit progress: {i}') # 记录进度信息
self.worker.progress.emit(i) # 发送进度信号,包含当前项索引
if self.worker.canceled: # 如果工作线程已取消
self.worker.finished.emit('', []) # 发送完成信号,包含空字符串和空列表
return # 退出循环,停止处理
# Give main thread time to process items:
self.worker.msleep(10) # 暂停10毫秒允许主线程处理事件
if self.worker: # 如果有工作线程
self.worker.finished.emit(self.filename, [])# 发送完成信号,包含文件名和空列表
@handle_sqlite_errors
# 写入数据库中的所有项,包括图片和文本项
def write(self):
# 检查是否为只读模式
if self.readonly: # 如果数据库为只读模式
raise sqlite3.OperationalError( # 抛出操作错误异常
'Attempt to write to a readonly database')
try: # 尝试执行写入操作
self.create_schema_on_new() # 创建新文件时创建数据库架构
self.write_data() # 写入所有数据
except Exception as e: # 捕获所有异常
#处理写入错误
if self.retry: # 如果允许重试
# Trying to recover failed
raise # 如果已经重试过一次,则放弃并抛出异常
else:
self.retry = True # 第一次失败后,标记为需要重试
# Try creating file from scratch and save again
logger.exception( # 尝试从头创建文件并再次保存
f'Updating to existing file {self.filename} failed') # 记录更新失败的异常信息
self.create_new = True # 标记为创建新文件
self._close_connection() # 关闭现有连接,以便重新建立连接
self.write() # 递归调用写入方法,尝试重新创建文件并保存
# 写入场景数据到数据库
def write_data(self):
to_delete = {row[0] for row in self.fetchall('SELECT id from ITEMS')} # 获取所有需要删除的项目ID
# We don't want to touch existing items that are displayed as errors:
keep = {item.original_save_id
for item in self.scene.items_by_type(BeeErrorItem.TYPE)} # 获取所有错误项的原始保存ID
logger.debug(f'Not saving error items: {keep}') # 记录不保存错误项的ID
to_delete = to_delete - keep # 从待删除列表中移除需要保留的项目
# 获取需要保存的项目列表
to_save = list(self.scene.items_for_save())
# 如果有工作线程,发送开始处理信号
if self.worker:
self.worker.begin_processing.emit(len(to_save))
# 遍历所有需要保存的项目
for i, item in enumerate(to_save):
logger.debug(f'Saving {item} with id {item.save_id}') # 记录正在保存的项目信息
if item.save_id: # 如果项目已存在数据库中
self.update_item(item) # 更新项目数据
to_delete.remove(item.save_id) # 从待删除列表中移除已更新的项目ID
else: # 如果项目不在数据库中
self.insert_item(item) # 插入新项目到数据库中
# 如果有工作线程,发送进度信号,包含当前项索引
if self.worker:
self.worker.progress.emit(i)
# 如果工作线程已取消,跳出循环,停止处理
if self.worker.canceled:
break
self.delete_items(to_delete) # 删除不再需要的项目
self.ex('VACUUM') # 执行VACUUM命令优化数据库空间
self.connection.commit() # 提交事务,确保所有更改生效
if self.worker: # 如果有工作线程
self.worker.finished.emit(self.filename, []) # 发送完成信号,包含文件名和空列表
# 从数据库中删除指定的项目
def delete_items(self, to_delete):
to_delete = [(pk,) for pk in to_delete] # 将待删除项目ID转换为元组列表
self.exmany('DELETE FROM items WHERE id=?', to_delete) # 执行批量删除项目的SQL语句
self.exmany('DELETE FROM sqlar WHERE item_id=?', to_delete) # 执行批量删除关联文件的SQL语句
self.connection.commit() # 提交事务,确保所有更改生效
# 向数据库中插入新项目
def insert_item(self, item):
# 插入项目基本信息到items表
self.ex(
'INSERT INTO items (type, x, y, z, scale, rotation, flip, '
'data) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(item.TYPE, item.pos().x(), item.pos().y(), item.zValue(), # 插入项目的类型、位置、Z值、缩放比例、旋转角度和翻转状态
item.scale(), item.rotation(), item.flip(), # 插入项目的缩放比例、旋转角度和翻转状态
json.dumps(item.get_extra_save_data()))) # 插入项目的额外保存数据
item.save_id = self.cursor.lastrowid # 获取新插入项目的ID
# 如果项目有像素图数据,也保存它
if hasattr(item, 'pixmap_to_bytes'):
pixmap, imgformat = item.pixmap_to_bytes() # 获取像素图的二进制数据和图像格式
name = item.get_filename_for_export(imgformat) # 获取导出文件名
# 将二进制数据插入到sqlar表
self.ex(
'INSERT INTO sqlar (item_id, name, mode, sz, data) '
'VALUES (?, ?, ?, ?, ?)',
(item.save_id, name, 0o644, len(pixmap), pixmap)) # 插入像素图数据到sqlar表
self.connection.commit() # 提交事务,确保所有更改生效
# 更新数据库中的项目数据
def update_item(self, item):
"""Update item data.
We only update the item data, not the pixmap data, as pixmap
data never changes and is also time-consuming to save.
"""
# 更新items表中的项目数据
self.ex(
'UPDATE items SET x=?, y=?, z=?, scale=?, rotation=?, flip=?, '
'data=? '
'WHERE id=?',
(item.pos().x(), item.pos().y(), item.zValue(), item.scale(), # 更新项目的位置、Z值、缩放比例
item.rotation(), item.flip(), # 更新项目的旋转角度和翻转状态
json.dumps(item.get_extra_save_data()), # 更新项目的额外保存数据
item.save_id)) # 更新项目的ID
self.connection.commit() # 提交事务,确保所有更改生效