Skip to main content

spreadsheet_kit/tools/
vba.rs

1use crate::model::{
2    VbaModuleDescriptor, VbaModuleSourceResponse, VbaProjectSummaryResponse,
3    VbaReferenceDescriptor, WorkbookId,
4};
5use crate::state::AppState;
6use anyhow::{Result, anyhow, bail};
7use schemars::JsonSchema;
8use serde::Deserialize;
9use std::fs::File;
10use std::io::Read;
11use std::path::Path;
12use std::sync::Arc;
13use zip::result::ZipError;
14
15const MAX_VBA_PROJECT_BYTES: u64 = 20 * 1024 * 1024;
16const DEFAULT_MAX_MODULES: u32 = 200;
17const DEFAULT_INCLUDE_REFERENCES: bool = true;
18
19const DEFAULT_OFFSET_LINES: u32 = 0;
20const DEFAULT_LIMIT_LINES: u32 = 200;
21const MAX_LIMIT_LINES: u32 = 5_000;
22
23#[derive(Debug, Deserialize, JsonSchema)]
24pub struct VbaProjectSummaryParams {
25    #[serde(alias = "workbook_id")]
26    pub workbook_or_fork_id: WorkbookId,
27    #[serde(default)]
28    pub max_modules: Option<u32>,
29    #[serde(default)]
30    pub include_references: Option<bool>,
31}
32
33pub async fn vba_project_summary(
34    state: Arc<AppState>,
35    params: VbaProjectSummaryParams,
36) -> Result<VbaProjectSummaryResponse> {
37    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
38    let raw = extract_vba_project_bin(&workbook.path)?;
39
40    if raw.is_none() {
41        return Ok(VbaProjectSummaryResponse {
42            workbook_id: workbook.id.clone(),
43            has_vba: false,
44            code_page: None,
45            sys_kind: None,
46            modules: Vec::new(),
47            modules_truncated: false,
48            references: Vec::new(),
49            references_truncated: false,
50            notes: vec!["No xl/vbaProject.bin found in workbook".to_string()],
51        });
52    }
53
54    let project = ovba::open_project(raw.unwrap())?;
55
56    let max_modules = params.max_modules.unwrap_or(DEFAULT_MAX_MODULES).max(1);
57    let include_references = params
58        .include_references
59        .unwrap_or(DEFAULT_INCLUDE_REFERENCES);
60
61    let mut modules: Vec<VbaModuleDescriptor> = Vec::new();
62    for module in project.modules.iter().take(max_modules as usize) {
63        let module_type = match module.module_type {
64            ovba::ModuleType::Procedural => "procedural",
65            ovba::ModuleType::DocClsDesigner => "doc_cls_designer",
66        }
67        .to_string();
68
69        modules.push(VbaModuleDescriptor {
70            name: module.name.clone(),
71            stream_name: module.stream_name.clone(),
72            doc_string: module.doc_string.clone(),
73            text_offset: module.text_offset as u64,
74            help_context: module.help_context,
75            module_type,
76            read_only: module.read_only,
77            private: module.private,
78        });
79    }
80
81    let modules_truncated = project.modules.len() > max_modules as usize;
82
83    let mut references: Vec<VbaReferenceDescriptor> = Vec::new();
84    let mut references_truncated = false;
85    if include_references {
86        for reference in project.references.iter() {
87            let (kind, debug) = summarize_reference(reference);
88            references.push(VbaReferenceDescriptor { kind, debug });
89            if references.len() >= 200 {
90                references_truncated = project.references.len() > references.len();
91                break;
92            }
93        }
94    }
95
96    let sys_kind = Some(
97        match project.information.sys_kind {
98            ovba::SysKind::Win16 => "win16",
99            ovba::SysKind::Win32 => "win32",
100            ovba::SysKind::MacOs => "macos",
101            ovba::SysKind::Win64 => "win64",
102        }
103        .to_string(),
104    );
105
106    Ok(VbaProjectSummaryResponse {
107        workbook_id: workbook.id.clone(),
108        has_vba: true,
109        code_page: Some(project.information.code_page),
110        sys_kind,
111        modules,
112        modules_truncated,
113        references,
114        references_truncated,
115        notes: Vec::new(),
116    })
117}
118
119#[derive(Debug, Deserialize, JsonSchema)]
120pub struct VbaModuleSourceParams {
121    #[serde(alias = "workbook_id")]
122    pub workbook_or_fork_id: WorkbookId,
123    pub module_name: String,
124    #[serde(default = "default_offset_lines")]
125    pub offset_lines: u32,
126    #[serde(default = "default_limit_lines")]
127    pub limit_lines: u32,
128}
129
130fn default_offset_lines() -> u32 {
131    DEFAULT_OFFSET_LINES
132}
133
134fn default_limit_lines() -> u32 {
135    DEFAULT_LIMIT_LINES
136}
137
138pub async fn vba_module_source(
139    state: Arc<AppState>,
140    params: VbaModuleSourceParams,
141) -> Result<VbaModuleSourceResponse> {
142    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
143    let raw = extract_vba_project_bin(&workbook.path)?
144        .ok_or_else(|| anyhow!("No xl/vbaProject.bin found in workbook"))?;
145
146    let project = ovba::open_project(raw)?;
147    let source = project.module_source(&params.module_name)?;
148
149    let offset = params.offset_lines;
150    let limit = params.limit_lines.clamp(1, MAX_LIMIT_LINES);
151
152    let mut total_lines: u32 = 0;
153    let mut selected: Vec<&str> = Vec::new();
154
155    for (idx, line) in source.lines().enumerate() {
156        let idx = idx as u32;
157        total_lines = total_lines.saturating_add(1);
158        if idx < offset {
159            continue;
160        }
161        if selected.len() >= limit as usize {
162            continue;
163        }
164        selected.push(line);
165    }
166
167    if total_lines == 0 && !source.is_empty() {
168        total_lines = 1;
169    }
170
171    let truncated = total_lines.saturating_sub(offset) > limit;
172
173    let mut page = selected.join("\n");
174    if !page.is_empty() {
175        page.push('\n');
176    }
177
178    Ok(VbaModuleSourceResponse {
179        workbook_id: workbook.id.clone(),
180        module_name: params.module_name,
181        offset_lines: offset,
182        limit_lines: limit,
183        total_lines,
184        truncated,
185        source: page,
186    })
187}
188
189fn extract_vba_project_bin(path: &Path) -> Result<Option<Vec<u8>>> {
190    let file = File::open(path)
191        .map_err(|e| anyhow!("failed to open workbook {}: {}", path.display(), e))?;
192
193    let mut archive = zip::ZipArchive::new(file)
194        .map_err(|e| anyhow!("failed to open workbook zip {}: {}", path.display(), e))?;
195
196    let mut entry = match archive.by_name("xl/vbaProject.bin") {
197        Ok(f) => f,
198        Err(ZipError::FileNotFound) => return Ok(None),
199        Err(e) => return Err(anyhow!("failed to locate xl/vbaProject.bin: {}", e)),
200    };
201
202    let declared_size = entry.size();
203    if declared_size > MAX_VBA_PROJECT_BYTES {
204        bail!(
205            "xl/vbaProject.bin too large ({} bytes; max {} bytes)",
206            declared_size,
207            MAX_VBA_PROJECT_BYTES
208        );
209    }
210
211    let mut buf: Vec<u8> = Vec::with_capacity(declared_size.min(1024 * 1024) as usize);
212    entry
213        .read_to_end(&mut buf)
214        .map_err(|e| anyhow!("failed to read xl/vbaProject.bin: {}", e))?;
215
216    if buf.len() as u64 > MAX_VBA_PROJECT_BYTES {
217        bail!(
218            "xl/vbaProject.bin too large after read ({} bytes; max {} bytes)",
219            buf.len(),
220            MAX_VBA_PROJECT_BYTES
221        );
222    }
223
224    Ok(Some(buf))
225}
226
227fn summarize_reference(reference: &ovba::Reference) -> (String, String) {
228    let kind = match reference {
229        ovba::Reference::Control(_) => "control",
230        ovba::Reference::Original(_) => "original",
231        ovba::Reference::Registered(_) => "registered",
232        ovba::Reference::Project(_) => "project",
233    }
234    .to_string();
235
236    let mut debug = format!("{:?}", reference);
237    const MAX_DEBUG_BYTES: usize = 4096;
238    if debug.len() > MAX_DEBUG_BYTES {
239        debug.truncate(MAX_DEBUG_BYTES);
240        debug.push_str("...[truncated]");
241    }
242
243    (kind, debug)
244}