1use crate::config::ServerConfig;
2#[cfg(feature = "recalc")]
3use crate::fork::{ForkConfig, ForkRegistry};
4use crate::model::{WorkbookId, WorkbookListResponse};
5#[cfg(feature = "recalc")]
6use crate::recalc::{
7 GlobalRecalcLock, LibreOfficeBackend, RecalcBackend, RecalcConfig, create_executor,
8};
9use crate::tools::filters::WorkbookFilter;
10use crate::utils::{hash_path_metadata, make_short_workbook_id};
11use crate::workbook::{WorkbookContext, build_workbook_list};
12use anyhow::{Result, anyhow};
13use lru::LruCache;
14use parking_lot::RwLock;
15use std::collections::HashMap;
16use std::fs;
17use std::num::NonZeroUsize;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use tokio::task;
21use walkdir::WalkDir;
22
23pub struct AppState {
24 config: Arc<ServerConfig>,
25 cache: RwLock<LruCache<WorkbookId, Arc<WorkbookContext>>>,
26 index: RwLock<HashMap<WorkbookId, PathBuf>>,
27 alias_index: RwLock<HashMap<String, WorkbookId>>,
28 #[cfg(feature = "recalc")]
29 fork_registry: Option<Arc<ForkRegistry>>,
30 #[cfg(feature = "recalc")]
31 recalc_backend: Option<Arc<dyn RecalcBackend>>,
32 #[cfg(feature = "recalc")]
33 recalc_semaphore: Option<GlobalRecalcLock>,
34}
35
36impl AppState {
37 pub fn new(config: Arc<ServerConfig>) -> Self {
38 let capacity = NonZeroUsize::new(config.cache_capacity.max(1)).unwrap();
39
40 #[cfg(feature = "recalc")]
41 let (fork_registry, recalc_backend, recalc_semaphore) = if config.recalc_enabled {
42 let fork_config = ForkConfig::default();
43 let registry = ForkRegistry::new(fork_config)
44 .map(Arc::new)
45 .map_err(|e| tracing::warn!("failed to init fork registry: {}", e))
46 .ok();
47
48 if let Some(registry) = ®istry {
49 registry.clone().start_cleanup_task();
50 }
51
52 let executor = create_executor(&RecalcConfig::default());
53 let backend: Arc<dyn RecalcBackend> = Arc::new(LibreOfficeBackend::new(executor));
54 let backend = if backend.is_available() {
55 Some(backend)
56 } else {
57 tracing::warn!("recalc backend not available (soffice not found)");
58 None
59 };
60
61 let semaphore = GlobalRecalcLock::new(config.max_concurrent_recalcs);
62
63 (registry, backend, Some(semaphore))
64 } else {
65 (None, None, None)
66 };
67
68 Self {
69 config,
70 cache: RwLock::new(LruCache::new(capacity)),
71 index: RwLock::new(HashMap::new()),
72 alias_index: RwLock::new(HashMap::new()),
73 #[cfg(feature = "recalc")]
74 fork_registry,
75 #[cfg(feature = "recalc")]
76 recalc_backend,
77 #[cfg(feature = "recalc")]
78 recalc_semaphore,
79 }
80 }
81
82 pub fn config(&self) -> Arc<ServerConfig> {
83 self.config.clone()
84 }
85
86 #[cfg(feature = "recalc")]
87 pub fn fork_registry(&self) -> Option<&Arc<ForkRegistry>> {
88 self.fork_registry.as_ref()
89 }
90
91 #[cfg(feature = "recalc")]
92 pub fn recalc_backend(&self) -> Option<&Arc<dyn RecalcBackend>> {
93 self.recalc_backend.as_ref()
94 }
95
96 #[cfg(feature = "recalc")]
97 pub fn recalc_semaphore(&self) -> Option<&GlobalRecalcLock> {
98 self.recalc_semaphore.as_ref()
99 }
100
101 pub fn list_workbooks(&self, filter: WorkbookFilter) -> Result<WorkbookListResponse> {
102 let response = build_workbook_list(&self.config, &filter)?;
103 {
104 let mut index = self.index.write();
105 let mut aliases = self.alias_index.write();
106 for descriptor in &response.workbooks {
107 let abs_path = self.config.resolve_path(PathBuf::from(&descriptor.path));
108 index.insert(descriptor.workbook_id.clone(), abs_path);
109 aliases.insert(
110 descriptor.short_id.to_ascii_lowercase(),
111 descriptor.workbook_id.clone(),
112 );
113 }
114 }
115 Ok(response)
116 }
117
118 pub async fn open_workbook(&self, workbook_id: &WorkbookId) -> Result<Arc<WorkbookContext>> {
119 let canonical = self.canonicalize_workbook_id(workbook_id)?;
120 {
121 let mut cache = self.cache.write();
122 if let Some(entry) = cache.get(&canonical) {
123 return Ok(entry.clone());
124 }
125 }
126
127 let path = self.resolve_workbook_path(&canonical)?;
128 let config = self.config.clone();
129 let path_buf = path.clone();
130 let workbook_id_clone = canonical.clone();
131 let workbook =
132 task::spawn_blocking(move || WorkbookContext::load(&config, &path_buf)).await??;
133 let workbook = Arc::new(workbook);
134
135 {
136 let mut aliases = self.alias_index.write();
137 aliases.insert(
138 workbook.short_id.to_ascii_lowercase(),
139 workbook_id_clone.clone(),
140 );
141 }
142
143 let mut cache = self.cache.write();
144 cache.put(workbook_id_clone, workbook.clone());
145 Ok(workbook)
146 }
147
148 pub fn close_workbook(&self, workbook_id: &WorkbookId) -> Result<()> {
149 let canonical = self.canonicalize_workbook_id(workbook_id)?;
150 let mut cache = self.cache.write();
151 cache.pop(&canonical);
152 Ok(())
153 }
154
155 pub fn evict_by_path(&self, path: &Path) {
156 let index = self.index.read();
157 let workbook_id = index
158 .iter()
159 .find(|(_, p)| *p == path)
160 .map(|(id, _)| id.clone());
161 drop(index);
162
163 if let Some(id) = workbook_id {
164 let mut cache = self.cache.write();
165 cache.pop(&id);
166 }
167 }
168
169 fn resolve_workbook_path(&self, workbook_id: &WorkbookId) -> Result<PathBuf> {
170 #[cfg(feature = "recalc")]
171 if let Some(registry) = &self.fork_registry
172 && let Some(fork_path) = registry.get_fork_path(workbook_id.as_str())
173 {
174 return Ok(fork_path);
175 }
176
177 if let Some(path) = self.index.read().get(workbook_id).cloned() {
178 return Ok(path);
179 }
180
181 let located = self.scan_for_workbook(workbook_id.as_str())?;
182 self.register_location(&located);
183 Ok(located.path)
184 }
185
186 fn canonicalize_workbook_id(&self, workbook_id: &WorkbookId) -> Result<WorkbookId> {
187 #[cfg(feature = "recalc")]
188 if let Some(registry) = &self.fork_registry
189 && registry.get_fork_path(workbook_id.as_str()).is_some()
190 {
191 return Ok(workbook_id.clone());
192 }
193
194 if self.index.read().contains_key(workbook_id) {
195 return Ok(workbook_id.clone());
196 }
197 let aliases = self.alias_index.read();
198 if let Some(mapped) = aliases.get(workbook_id.as_str()).cloned() {
199 return Ok(mapped);
200 }
201 let lowered = workbook_id.as_str().to_ascii_lowercase();
202 if lowered != workbook_id.as_str()
203 && let Some(mapped) = aliases.get(&lowered).cloned()
204 {
205 return Ok(mapped);
206 }
207
208 let located = self.scan_for_workbook(workbook_id.as_str())?;
209 let canonical = located.workbook_id.clone();
210 self.register_location(&located);
211 Ok(canonical)
212 }
213
214 fn scan_for_workbook(&self, candidate: &str) -> Result<LocatedWorkbook> {
215 let candidate_lower = candidate.to_ascii_lowercase();
216
217 if let Some(single) = self.config.single_workbook() {
218 let metadata = fs::metadata(single)?;
219 let canonical = WorkbookId(hash_path_metadata(single, &metadata));
220 let slug = single
221 .file_stem()
222 .map(|s| s.to_string_lossy().to_string())
223 .unwrap_or_else(|| "workbook".to_string());
224 let short_id = make_short_workbook_id(&slug, canonical.as_str());
225 if candidate == canonical.as_str() || candidate_lower == short_id {
226 return Ok(LocatedWorkbook {
227 workbook_id: canonical,
228 short_id,
229 path: single.to_path_buf(),
230 });
231 }
232 return Err(anyhow!(
233 "workbook id {} not found in single-workbook mode (expected {} or {})",
234 candidate,
235 canonical.as_str(),
236 short_id
237 ));
238 }
239
240 for entry in WalkDir::new(&self.config.workspace_root) {
241 let entry = entry?;
242 if !entry.file_type().is_file() {
243 continue;
244 }
245 let path = entry.path();
246 if !has_supported_extension(&self.config.supported_extensions, path) {
247 continue;
248 }
249 let metadata = entry.metadata()?;
250 let canonical = WorkbookId(hash_path_metadata(path, &metadata));
251 let slug = path
252 .file_stem()
253 .map(|s| s.to_string_lossy().to_string())
254 .unwrap_or_else(|| "workbook".to_string());
255 let short_id = make_short_workbook_id(&slug, canonical.as_str());
256 if candidate == canonical.as_str() || candidate_lower == short_id {
257 return Ok(LocatedWorkbook {
258 workbook_id: canonical,
259 short_id,
260 path: path.to_path_buf(),
261 });
262 }
263 }
264 Err(anyhow!("workbook id {} not found", candidate))
265 }
266
267 fn register_location(&self, located: &LocatedWorkbook) {
268 self.index
269 .write()
270 .insert(located.workbook_id.clone(), located.path.clone());
271 self.alias_index.write().insert(
272 located.short_id.to_ascii_lowercase(),
273 located.workbook_id.clone(),
274 );
275 }
276}
277
278struct LocatedWorkbook {
279 workbook_id: WorkbookId,
280 short_id: String,
281 path: PathBuf,
282}
283
284fn has_supported_extension(allowed: &[String], path: &Path) -> bool {
285 path.extension()
286 .and_then(|ext| ext.to_str())
287 .map(|ext| {
288 let lower = ext.to_ascii_lowercase();
289 allowed.iter().any(|candidate| candidate == &lower)
290 })
291 .unwrap_or(false)
292}