- Fix import_vba_access() to actually save database via DoCmd.Save() - Update stale module docstring in extract_vba.py - Validate file extensions in get_file_type() instead of silently defaulting to excel - Scan both Excel/ and Access/ directories in interactive mode Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
525 lines
18 KiB
Python
525 lines
18 KiB
Python
"""
|
||
VBA代码导入工具
|
||
使用 .env 配置直接导入 VBA 代码,无需元数据文件
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import shutil
|
||
import tempfile
|
||
from pathlib import Path
|
||
from typing import Dict, List, Tuple
|
||
|
||
# 加载 .env 配置文件
|
||
try:
|
||
from dotenv import load_dotenv
|
||
load_dotenv()
|
||
except ImportError:
|
||
print("警告: 未安装 python-dotenv 库,将使用默认配置")
|
||
print("建议运行: pip install python-dotenv")
|
||
|
||
try:
|
||
import win32com.client as win32
|
||
except ImportError:
|
||
print("错误: 未安装 pywin32 库")
|
||
print("请运行: pip install pywin32")
|
||
sys.exit(1)
|
||
|
||
# ==================== 配置区域 ====================
|
||
# 从 .env 文件读取配置
|
||
# 目标文件路径(支持 .xlsm / .accdb / .mdb)
|
||
TARGET_FILE = os.getenv("TARGET_FILE", "").strip() or None
|
||
# VBA代码输出目录(如果未设置,则使用源文件同目录下的VBA文件夹)
|
||
VBA_OUTPUT_DIR = os.getenv("VBA_OUTPUT_DIR", "").strip() or None
|
||
# =================================================
|
||
|
||
# 常量定义
|
||
STANDARD_MODULE_DIR = "Modules"
|
||
CLASS_MODULE_DIR = "ClassModules"
|
||
DOCUMENT_MODULE_DIR = "DocumentModules"
|
||
FORMS_DIR = "Forms"
|
||
|
||
# 支持的文件类型
|
||
ACCESS_EXTENSIONS = {'.accdb', '.mdb'}
|
||
EXCEL_EXTENSIONS = {'.xlsm', '.xls', '.xlsb'}
|
||
|
||
|
||
def get_file_type(file_path: Path) -> str:
|
||
"""
|
||
根据文件扩展名判断文件类型
|
||
|
||
Returns:
|
||
'access' 或 'excel'
|
||
|
||
Raises:
|
||
ValueError: 不支持的文件扩展名
|
||
"""
|
||
ext = file_path.suffix.lower()
|
||
if ext in ACCESS_EXTENSIONS:
|
||
return 'access'
|
||
if ext in EXCEL_EXTENSIONS:
|
||
return 'excel'
|
||
raise ValueError(f"不支持的文件类型: {ext}(支持: .xlsm, .xls, .xlsb, .accdb, .mdb)")
|
||
|
||
|
||
class VBAImporter:
|
||
"""VBA代码导入器"""
|
||
|
||
def __init__(self, vba_dir: str, target_file: str):
|
||
"""
|
||
初始化VBA导入器
|
||
|
||
Args:
|
||
vba_dir: VBA代码目录(包含 Modules, ClassModules 等子目录)
|
||
target_file: 目标 Excel 文件路径
|
||
"""
|
||
self.vba_dir = Path(vba_dir).resolve()
|
||
self.target_file = Path(target_file).resolve()
|
||
|
||
def _scan_modules(self) -> List[Dict]:
|
||
"""
|
||
扫描 VBA 目录,收集所有模块信息
|
||
|
||
Returns:
|
||
模块信息列表,每个元素包含:
|
||
- name: 模块名称
|
||
- type: 模块类型目录
|
||
- file_path: 源文件完整路径
|
||
- ext: 文件扩展名
|
||
"""
|
||
modules = []
|
||
|
||
# 定义扫描目录和对应的扩展名
|
||
scan_dirs = [
|
||
(STANDARD_MODULE_DIR, ".bas"),
|
||
(CLASS_MODULE_DIR, ".cls"),
|
||
(DOCUMENT_MODULE_DIR, ".cls"),
|
||
(FORMS_DIR, ".frm"),
|
||
]
|
||
|
||
for dir_name, ext in scan_dirs:
|
||
dir_path = self.vba_dir / dir_name
|
||
if not dir_path.exists():
|
||
continue
|
||
|
||
for file_path in dir_path.glob(f"*{ext}"):
|
||
modules.append({
|
||
"name": file_path.stem, # 文件名不含扩展名
|
||
"type": dir_name,
|
||
"file_path": file_path,
|
||
"ext": ext
|
||
})
|
||
|
||
return modules
|
||
|
||
def _clean_component_name(self, name: str) -> str:
|
||
"""去除模块名称中的扩展名"""
|
||
lower_name = name.lower()
|
||
for ext in ['.cls', '.bas', '.frm']:
|
||
if lower_name.endswith(ext):
|
||
return name[:-len(ext)]
|
||
return name
|
||
|
||
def _reconstruct_file_content(self, code_path: Path, module_name: str, module_type: str) -> Path:
|
||
"""
|
||
读取纯代码文件,重建完整的导入文件
|
||
关键逻辑:
|
||
1. ClassModules 需要 'VERSION 1.0 CLASS' 头部,否则会被识别为标准模块。
|
||
2. 使用 GB18030 编码写入,防止中文乱码。
|
||
"""
|
||
# 1. 读取源代码 (UTF-8)
|
||
with open(code_path, 'r', encoding='utf-8') as f:
|
||
code_body = f.read()
|
||
|
||
# 创建临时文件
|
||
temp_dir = Path(tempfile.gettempdir()) / "vba_import_temp"
|
||
temp_dir.mkdir(exist_ok=True)
|
||
|
||
# 确定扩展名
|
||
orig_ext = code_path.suffix
|
||
temp_file_path = temp_dir / f"{module_name}{orig_ext}"
|
||
|
||
content_lines = []
|
||
|
||
# -----------------------------------------------------------
|
||
# 【关键修复】如果是类模块,必须添加 VERSION 头部块
|
||
# -----------------------------------------------------------
|
||
if module_type == CLASS_MODULE_DIR:
|
||
content_lines.append("VERSION 1.0 CLASS")
|
||
content_lines.append("BEGIN")
|
||
content_lines.append(" MultiUse = -1 'True")
|
||
content_lines.append("END")
|
||
|
||
# 2. 重建 Attribute VB_Name
|
||
content_lines.append(f'Attribute VB_Name = "{module_name}"')
|
||
|
||
# 注意:由于我们移除了元数据,不再有其他属性信息
|
||
# 如果需要其他属性,需要从源文件中解析或在代码中显式声明
|
||
|
||
content_lines.append("")
|
||
content_lines.append(code_body)
|
||
|
||
# 4. 写入临时文件 (GB18030 防止乱码)
|
||
try:
|
||
with open(temp_file_path, 'w', encoding='gb18030', errors='replace') as f:
|
||
f.write('\n'.join(content_lines))
|
||
except Exception as e:
|
||
print(f" [警告] 编码转换失败,尝试回退到 utf-8: {e}")
|
||
with open(temp_file_path, 'w', encoding='utf-8') as f:
|
||
f.write('\n'.join(content_lines))
|
||
|
||
return temp_file_path
|
||
|
||
def import_vba(self):
|
||
"""执行导入过程"""
|
||
if not self.target_file.exists():
|
||
print(f"错误: 找不到目标 Excel 文件: {self.target_file}")
|
||
return False
|
||
|
||
if not self.vba_dir.exists():
|
||
print(f"错误: 找不到 VBA 代码目录: {self.vba_dir}")
|
||
return False
|
||
|
||
print(f"正在打开 Excel 文件: {self.target_file.name} ...")
|
||
|
||
excel = None
|
||
workbook = None
|
||
try:
|
||
excel = win32.Dispatch("Excel.Application")
|
||
excel.Visible = False
|
||
excel.DisplayAlerts = False
|
||
|
||
workbook = excel.Workbooks.Open(str(self.target_file))
|
||
|
||
try:
|
||
vb_project = workbook.VBProject
|
||
except Exception:
|
||
print("错误: 无法访问 VBA 项目。请确保信任对 VBA 工程对象模型的访问。")
|
||
return False
|
||
|
||
print("开始导入模块...\n")
|
||
|
||
# 扫描所有模块
|
||
modules = self._scan_modules()
|
||
temp_files_created = []
|
||
|
||
if not modules:
|
||
print("警告: 未找到任何 VBA 模块文件")
|
||
return False
|
||
|
||
for module_info in modules:
|
||
module_name = module_info["name"]
|
||
module_type_dir = module_info["type"]
|
||
source_code_path = module_info["file_path"]
|
||
|
||
component = None
|
||
try:
|
||
component = vb_project.VBComponents(module_name)
|
||
except:
|
||
component = None
|
||
|
||
# 标准模块和类模块支持删除重建
|
||
is_reloadable = module_type_dir in [STANDARD_MODULE_DIR, CLASS_MODULE_DIR]
|
||
|
||
# ---------------------------------------------------------
|
||
# 策略 A: 导入文件模式 (Modules, ClassModules)
|
||
# ---------------------------------------------------------
|
||
if is_reloadable:
|
||
if component:
|
||
try:
|
||
vb_project.VBComponents.Remove(component)
|
||
except Exception as e:
|
||
print(f" [警告] 无法移除 {module_name}: {e},将尝试仅更新代码")
|
||
is_reloadable = False
|
||
|
||
if is_reloadable:
|
||
# 生成临时导入文件
|
||
temp_file = self._reconstruct_file_content(source_code_path, module_name, module_type_dir)
|
||
temp_files_created.append(temp_file)
|
||
|
||
try:
|
||
vb_project.VBComponents.Import(str(temp_file))
|
||
print(f" [导入] {module_name} ({module_type_dir})")
|
||
except Exception as e:
|
||
print(f" [错误] 导入 {module_name} 失败: {e}")
|
||
|
||
# ---------------------------------------------------------
|
||
# 策略 B: 字符串注入模式 (Sheet, Workbook, Forms)
|
||
# ---------------------------------------------------------
|
||
if not is_reloadable:
|
||
if not component:
|
||
if module_type_dir == FORMS_DIR:
|
||
print(f" [警告] 无法恢复 UserForm '{module_name}',跳过。")
|
||
continue
|
||
elif module_type_dir == DOCUMENT_MODULE_DIR:
|
||
print(f" [警告] 找不到文档对象 '{module_name}',跳过。")
|
||
continue
|
||
try:
|
||
component = vb_project.VBComponents.Add(1)
|
||
component.Name = module_name
|
||
except:
|
||
print(f" [错误] 无法创建组件 {module_name}")
|
||
continue
|
||
|
||
try:
|
||
code_module = component.CodeModule
|
||
num_lines = code_module.CountOfLines
|
||
if num_lines > 0:
|
||
code_module.DeleteLines(1, num_lines)
|
||
|
||
# 直接读取 UTF-8 字符串到内存
|
||
with open(source_code_path, 'r', encoding='utf-8') as f:
|
||
new_code = f.read()
|
||
|
||
if new_code.strip():
|
||
code_module.AddFromString(new_code)
|
||
|
||
print(f" [更新] {module_name} ({module_type_dir}) - 代码已更新")
|
||
except Exception as e:
|
||
print(f" [错误] 更新代码 {module_name} 失败: {e}")
|
||
|
||
# 清理
|
||
for p in temp_files_created:
|
||
try:
|
||
if p.exists(): p.unlink()
|
||
except: pass
|
||
try:
|
||
temp_dir = Path(tempfile.gettempdir()) / "vba_import_temp"
|
||
if temp_dir.exists(): shutil.rmtree(temp_dir)
|
||
except: pass
|
||
|
||
print("\n正在编译 VBA 项目...")
|
||
try:
|
||
workbook.Save()
|
||
print("已保存更改。")
|
||
except Exception as e:
|
||
print(f"保存文件时出错: {e}")
|
||
|
||
print(f"\n导入完成!目标文件: {self.target_file.name}")
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"\n发生未处理的错误: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return False
|
||
|
||
finally:
|
||
if workbook:
|
||
try: workbook.Close(SaveChanges=False)
|
||
except: pass
|
||
if excel:
|
||
try: excel.Quit()
|
||
except: pass
|
||
|
||
def import_vba_access(self):
|
||
"""使用Access COM导入VBA代码"""
|
||
if not self.target_file.exists():
|
||
print(f"错误: 找不到目标 Access 文件: {self.target_file}")
|
||
return False
|
||
|
||
if not self.vba_dir.exists():
|
||
print(f"错误: 找不到 VBA 代码目录: {self.vba_dir}")
|
||
return False
|
||
|
||
print(f"正在打开 Access 数据库: {self.target_file.name} ...")
|
||
|
||
access = None
|
||
try:
|
||
access = win32.Dispatch("Access.Application")
|
||
access.Visible = False
|
||
access.OpenCurrentDatabase(str(self.target_file))
|
||
|
||
try:
|
||
vb_project = access.VBE.VBProjects(1)
|
||
except Exception:
|
||
print("错误: 无法访问 VBA 项目。请确保信任对 VBA 工程对象模型的访问。")
|
||
return False
|
||
|
||
print("开始导入模块...\n")
|
||
|
||
# 扫描所有模块(Access 只导入标准模块和类模块)
|
||
scan_dirs = [
|
||
(STANDARD_MODULE_DIR, ".bas"),
|
||
(CLASS_MODULE_DIR, ".cls"),
|
||
]
|
||
modules = []
|
||
for dir_name, ext in scan_dirs:
|
||
dir_path = self.vba_dir / dir_name
|
||
if not dir_path.exists():
|
||
continue
|
||
for file_path in dir_path.glob(f"*{ext}"):
|
||
modules.append({
|
||
"name": file_path.stem,
|
||
"type": dir_name,
|
||
"file_path": file_path,
|
||
"ext": ext
|
||
})
|
||
|
||
if not modules:
|
||
print("警告: 未找到任何 VBA 模块文件")
|
||
return False
|
||
|
||
temp_files_created = []
|
||
|
||
for module_info in modules:
|
||
module_name = module_info["name"]
|
||
module_type_dir = module_info["type"]
|
||
source_code_path = module_info["file_path"]
|
||
|
||
component = None
|
||
try:
|
||
component = vb_project.VBComponents(module_name)
|
||
except:
|
||
component = None
|
||
|
||
# 移除已存在的组件
|
||
if component:
|
||
try:
|
||
vb_project.VBComponents.Remove(component)
|
||
except Exception as e:
|
||
print(f" [警告] 无法移除 {module_name}: {e},将尝试更新代码")
|
||
# 回退到字符串注入
|
||
try:
|
||
code_module = component.CodeModule
|
||
num_lines = code_module.CountOfLines
|
||
if num_lines > 0:
|
||
code_module.DeleteLines(1, num_lines)
|
||
with open(source_code_path, 'r', encoding='utf-8') as f:
|
||
new_code = f.read()
|
||
if new_code.strip():
|
||
code_module.AddFromString(new_code)
|
||
print(f" [更新] {module_name} ({module_type_dir}) - 代码已更新")
|
||
except Exception as e2:
|
||
print(f" [错误] 更新代码 {module_name} 失败: {e2}")
|
||
continue
|
||
|
||
# 生成临时导入文件并导入
|
||
temp_file = self._reconstruct_file_content(source_code_path, module_name, module_type_dir)
|
||
temp_files_created.append(temp_file)
|
||
|
||
try:
|
||
vb_project.VBComponents.Import(str(temp_file))
|
||
print(f" [导入] {module_name} ({module_type_dir})")
|
||
except Exception as e:
|
||
print(f" [错误] 导入 {module_name} 失败: {e}")
|
||
|
||
# 清理临时文件
|
||
for p in temp_files_created:
|
||
try:
|
||
if p.exists(): p.unlink()
|
||
except: pass
|
||
try:
|
||
temp_dir = Path(tempfile.gettempdir()) / "vba_import_temp"
|
||
if temp_dir.exists(): shutil.rmtree(temp_dir)
|
||
except: pass
|
||
|
||
print("\n正在保存...")
|
||
try:
|
||
access.DoCmd.Save()
|
||
print("已保存更改。")
|
||
except Exception as e:
|
||
print(f"保存时出错: {e}")
|
||
|
||
print(f"\n导入完成!目标文件: {self.target_file.name}")
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"\n发生未处理的错误: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return False
|
||
|
||
finally:
|
||
if access:
|
||
try:
|
||
access.CloseCurrentDatabase()
|
||
except:
|
||
pass
|
||
try:
|
||
access.Quit()
|
||
except:
|
||
pass
|
||
|
||
def main():
|
||
print("=" * 60)
|
||
print("VBA代码导入工具 (V5.0 - 支持Excel和Access)")
|
||
print("=" * 60)
|
||
print()
|
||
|
||
script_dir = Path(__file__).parent
|
||
|
||
# 检查配置
|
||
if not TARGET_FILE:
|
||
print("错误: 未配置 TARGET_FILE")
|
||
print("请在 .env 文件中设置目标文件路径(支持.xlsm和.accdb)")
|
||
return
|
||
|
||
# 确定目标文件路径
|
||
target_path = Path(TARGET_FILE)
|
||
if not target_path.is_absolute():
|
||
target_path = script_dir / target_path
|
||
|
||
if not target_path.exists():
|
||
print(f"错误: 配置的文件不存在: {target_path}")
|
||
return
|
||
|
||
file_type = get_file_type(target_path)
|
||
|
||
# 确定 VBA 代码目录
|
||
if VBA_OUTPUT_DIR:
|
||
vba_path = Path(VBA_OUTPUT_DIR)
|
||
if not vba_path.is_absolute():
|
||
vba_path = script_dir / vba_path
|
||
else:
|
||
# 使用目标文件同目录下的 VBA 文件夹
|
||
vba_path = target_path.parent / "VBA"
|
||
|
||
if not vba_path.exists():
|
||
print(f"错误: VBA 代码目录不存在: {vba_path}")
|
||
print()
|
||
print("提示:")
|
||
print(" 1. 确保已运行 extract_vba.py 提取 VBA 代码")
|
||
print(" 2. 或在 .env 文件中设置 VBA_OUTPUT_DIR 指定代码目录")
|
||
return
|
||
|
||
type_label = "Access" if file_type == "access" else "Excel"
|
||
print(f"目标文件: {target_path.name} ({type_label})")
|
||
print(f"VBA 代码目录: {vba_path}")
|
||
print()
|
||
|
||
# 确认操作
|
||
print("=" * 60)
|
||
print(f"警告: 此操作将覆盖目标{type_label}文件中的 VBA 代码。")
|
||
choice = input("\n确认继续? (y/n): ").lower().strip()
|
||
|
||
if choice != 'y':
|
||
print("操作已取消")
|
||
return
|
||
|
||
importer = VBAImporter(str(vba_path), str(target_path))
|
||
|
||
# 显示模块数量
|
||
modules = importer._scan_modules()
|
||
print(f"找到 {len(modules)} 个模块文件")
|
||
print()
|
||
print("=" * 60)
|
||
print()
|
||
|
||
# 根据文件类型选择导入方法
|
||
if file_type == 'access':
|
||
success = importer.import_vba_access()
|
||
else:
|
||
success = importer.import_vba()
|
||
|
||
print()
|
||
print("=" * 60)
|
||
if success:
|
||
print("导入成功完成!")
|
||
else:
|
||
print("导入失败")
|
||
print("=" * 60)
|
||
|
||
if __name__ == "__main__":
|
||
main()
|