Files
VBAExtractor/import_vba.py
Misaka_Company d156fdb2d8 fix: address code review issues for Access support
- 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>
2026-05-11 09:44:55 +08:00

525 lines
18 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.
"""
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()