spreadsheet_mcp/repository/
virtual_workspace.rs1use super::{ResolvedWorkbookRef, WorkbookRepository, WorkbookSource};
2use crate::caps::BackendCaps;
3use crate::config::ServerConfig;
4use crate::model::{WorkbookDescriptor, WorkbookId, WorkbookListResponse};
5use crate::tools::filters::WorkbookFilter;
6use crate::utils::{hash_bytes_sha256_hex, hash_path_identity, make_short_workbook_id};
7use crate::workbook::WorkbookContext;
8use anyhow::{Result, anyhow};
9use parking_lot::RwLock;
10use std::collections::HashMap;
11use std::path::Path;
12use std::sync::Arc;
13
14#[derive(Debug, Clone)]
15pub struct VirtualWorkbookInput {
16 pub key: String,
17 pub slug: Option<String>,
18 pub bytes: Vec<u8>,
19}
20
21#[derive(Debug, Clone)]
22struct VirtualWorkbook {
23 key: String,
24 slug: String,
25 workbook_id: WorkbookId,
26 short_id: String,
27 revision_id: String,
28 bytes: Arc<Vec<u8>>,
29}
30
31pub struct VirtualWorkspaceRepository {
32 config: Arc<ServerConfig>,
33 entries: RwLock<HashMap<WorkbookId, VirtualWorkbook>>,
34 alias_index: RwLock<HashMap<String, WorkbookId>>,
35}
36
37impl VirtualWorkspaceRepository {
38 pub fn new(config: Arc<ServerConfig>) -> Self {
39 Self {
40 config,
41 entries: RwLock::new(HashMap::new()),
42 alias_index: RwLock::new(HashMap::new()),
43 }
44 }
45
46 pub fn register(&self, input: VirtualWorkbookInput) -> WorkbookId {
47 let key = input.key;
48 let slug = input.slug.unwrap_or_else(|| sanitize_slug(&key));
49 let workbook_id = WorkbookId(hash_path_identity(Path::new(&format!("virtual/{key}"))));
50 let short_id = make_short_workbook_id(&slug, workbook_id.as_str());
51 let revision_id = hash_bytes_sha256_hex(&input.bytes);
52 let entry = VirtualWorkbook {
53 key: key.clone(),
54 slug,
55 workbook_id: workbook_id.clone(),
56 short_id: short_id.clone(),
57 revision_id,
58 bytes: Arc::new(input.bytes),
59 };
60
61 self.entries.write().insert(workbook_id.clone(), entry);
62 let mut aliases = self.alias_index.write();
63 aliases.insert(key.to_ascii_lowercase(), workbook_id.clone());
64 aliases.insert(short_id.to_ascii_lowercase(), workbook_id.clone());
65 aliases.insert(
66 workbook_id.as_str().to_ascii_lowercase(),
67 workbook_id.clone(),
68 );
69 workbook_id
70 }
71
72 fn lookup(&self, id_or_alias: &WorkbookId) -> Option<VirtualWorkbook> {
73 if let Some(entry) = self.entries.read().get(id_or_alias) {
74 return Some(entry.clone());
75 }
76
77 let lowered = id_or_alias.as_str().to_ascii_lowercase();
78 let id = self.alias_index.read().get(&lowered).cloned()?;
79 self.entries.read().get(&id).cloned()
80 }
81}
82
83impl WorkbookRepository for VirtualWorkspaceRepository {
84 fn list(&self, filter: &WorkbookFilter) -> Result<WorkbookListResponse> {
85 let mut workbooks = Vec::new();
86 for entry in self.entries.read().values() {
87 let virtual_path = Path::new(&entry.key);
88 if !filter.matches(&entry.slug, None, virtual_path) {
89 continue;
90 }
91
92 workbooks.push(WorkbookDescriptor {
93 workbook_id: entry.workbook_id.clone(),
94 short_id: entry.short_id.clone(),
95 slug: entry.slug.clone(),
96 folder: None,
97 path: Some(format!("virtual/{}", entry.key)),
98 client_path: None,
99 bytes: entry.bytes.len() as u64,
100 last_modified: None,
101 revision_id: Some(entry.revision_id.clone()),
102 caps: Some(BackendCaps::xlsx()),
103 });
104 }
105
106 workbooks.sort_by(|a, b| a.slug.cmp(&b.slug));
107
108 Ok(WorkbookListResponse {
109 workbooks,
110 next_offset: None,
111 })
112 }
113
114 fn resolve(&self, id_or_alias: &WorkbookId) -> Result<ResolvedWorkbookRef> {
115 let Some(entry) = self.lookup(id_or_alias) else {
116 return Err(anyhow!("workbook id {} not found", id_or_alias.as_str()));
117 };
118
119 Ok(ResolvedWorkbookRef {
120 workbook_id: entry.workbook_id,
121 short_id: entry.short_id,
122 revision_id: Some(entry.revision_id),
123 source: WorkbookSource::Virtual(entry.key),
124 })
125 }
126
127 fn load_context(&self, resolved: &ResolvedWorkbookRef) -> Result<WorkbookContext> {
128 let WorkbookSource::Virtual(_) = &resolved.source else {
129 return Err(anyhow!(
130 "virtual repository cannot load non-virtual workbook"
131 ));
132 };
133
134 let entry = self
135 .entries
136 .read()
137 .get(&resolved.workbook_id)
138 .cloned()
139 .ok_or_else(|| anyhow!("virtual workbook {} not found", resolved.workbook_id.0))?;
140
141 WorkbookContext::load_from_bytes(
142 &self.config,
143 &entry.key,
144 entry.bytes.as_slice(),
145 resolved.workbook_id.clone(),
146 resolved.short_id.clone(),
147 resolved.revision_id.clone(),
148 )
149 }
150}
151
152fn sanitize_slug(value: &str) -> String {
153 let mut out = value
154 .chars()
155 .map(|ch| {
156 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
157 ch
158 } else {
159 '-'
160 }
161 })
162 .collect::<String>();
163 out.make_ascii_lowercase();
164 while out.contains("--") {
165 out = out.replace("--", "-");
166 }
167 out.trim_matches('-').to_string()
168}