1use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct FileRef {
29 pub id: String,
31
32 pub sub: String,
34
35 pub name: String,
37
38 pub size: u64,
40
41 pub mime: String,
43
44 pub sha256: String,
46
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub shard: Option<u32>,
50}
51
52impl FileRef {
53 pub fn from_json(json: &str) -> Option<Self> {
59 serde_json::from_str(json).ok()
60 }
61
62 pub fn from_json_value(value: &serde_json::Value) -> Option<Self> {
68 match value {
69 serde_json::Value::String(s) => Self::from_json(s),
70 serde_json::Value::Object(_) => serde_json::from_value(value.clone()).ok(),
71 _ => None,
72 }
73 }
74
75 pub fn to_json(&self) -> String {
77 serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
78 }
79
80 pub fn download_url(&self, base_url: &str, namespace: &str, table: &str) -> String {
90 let base = base_url.trim_end_matches('/');
91 format!("{}/v1/files/{}/{}/{}/{}", base, namespace, table, self.sub, self.stored_name())
92 }
93
94 pub fn relative_url(&self, namespace: &str, table: &str) -> String {
100 format!("/v1/files/{}/{}/{}/{}", namespace, table, self.sub, self.stored_name())
101 }
102
103 pub fn stored_name(&self) -> String {
112 let sanitized = Self::sanitize_filename(&self.name);
113 let ext = Self::extract_extension(&self.name);
114
115 if sanitized.is_empty() {
116 format!("{}.{}", self.id, ext)
117 } else {
118 format!("{}-{}.{}", self.id, sanitized, ext)
119 }
120 }
121
122 pub fn relative_path(&self) -> String {
127 let stored_name = self.stored_name();
128 match self.shard {
129 Some(shard_id) => format!("shard-{}/{}/{}", shard_id, self.sub, stored_name),
130 None => format!("{}/{}", self.sub, stored_name),
131 }
132 }
133
134 pub fn is_image(&self) -> bool {
140 self.mime.starts_with("image/")
141 }
142
143 pub fn is_video(&self) -> bool {
145 self.mime.starts_with("video/")
146 }
147
148 pub fn is_audio(&self) -> bool {
150 self.mime.starts_with("audio/")
151 }
152
153 pub fn is_pdf(&self) -> bool {
155 self.mime == "application/pdf"
156 }
157
158 pub fn type_description(&self) -> String {
162 if self.is_image() {
163 return "Image".to_string();
164 }
165 if self.is_video() {
166 return "Video".to_string();
167 }
168 if self.is_audio() {
169 return "Audio".to_string();
170 }
171 if self.is_pdf() {
172 return "PDF Document".to_string();
173 }
174 if let Some((_type_part, subtype)) = self.mime.split_once('/') {
176 format!("{} File", subtype.to_uppercase())
177 } else {
178 "File".to_string()
179 }
180 }
181
182 pub fn format_size(&self) -> String {
190 const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
191 let mut size = self.size as f64;
192 let mut idx = 0;
193
194 while size >= 1024.0 && idx < UNITS.len() - 1 {
195 size /= 1024.0;
196 idx += 1;
197 }
198
199 if idx == 0 {
200 format!("{} {}", size as u64, UNITS[idx])
201 } else {
202 format!("{:.1} {}", size, UNITS[idx])
203 }
204 }
205
206 fn sanitize_filename(name: &str) -> String {
212 let name_without_ext = name.rsplit_once('.').map(|(n, _)| n).unwrap_or(name);
213
214 let sanitized: String = name_without_ext
215 .chars()
216 .filter_map(|c| {
217 if c.is_ascii_alphanumeric() {
218 Some(c.to_ascii_lowercase())
219 } else if c == ' ' || c == '_' || c == '-' {
220 Some('-')
221 } else {
222 None
223 }
224 })
225 .take(50)
226 .collect();
227
228 let mut result = String::with_capacity(sanitized.len());
230 let mut last_was_dash = true;
231 for c in sanitized.chars() {
232 if c == '-' {
233 if !last_was_dash {
234 result.push(c);
235 }
236 last_was_dash = true;
237 } else {
238 result.push(c);
239 last_was_dash = false;
240 }
241 }
242 result.trim_end_matches('-').to_string()
243 }
244
245 fn extract_extension(name: &str) -> String {
247 name.rsplit_once('.')
248 .map(|(_, ext)| {
249 let ext_lower = ext.to_ascii_lowercase();
250 if ext_lower.len() <= 10 && ext_lower.chars().all(|c| c.is_ascii_alphanumeric()) {
251 ext_lower
252 } else {
253 "bin".to_string()
254 }
255 })
256 .unwrap_or_else(|| "bin".to_string())
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn parse_from_json_string() {
266 let json = r#"{"id":"123","sub":"f0001","name":"test.png","size":1024,"mime":"image/png","sha256":"abc"}"#;
267 let fr = FileRef::from_json(json).unwrap();
268 assert_eq!(fr.id, "123");
269 assert_eq!(fr.sub, "f0001");
270 assert_eq!(fr.name, "test.png");
271 assert_eq!(fr.size, 1024);
272 assert!(fr.is_image());
273 }
274
275 #[test]
276 fn parse_from_json_value_object() {
277 let val = serde_json::json!({
278 "id": "456", "sub": "f0002", "name": "doc.pdf",
279 "size": 2048, "mime": "application/pdf", "sha256": "def"
280 });
281 let fr = FileRef::from_json_value(&val).unwrap();
282 assert!(fr.is_pdf());
283 assert_eq!(fr.type_description(), "PDF Document");
284 }
285
286 #[test]
287 fn parse_from_json_value_string() {
288 let inner = r#"{"id":"789","sub":"f0001","name":"a.txt","size":10,"mime":"text/plain","sha256":"x"}"#;
289 let val = serde_json::Value::String(inner.to_string());
290 let fr = FileRef::from_json_value(&val).unwrap();
291 assert_eq!(fr.id, "789");
292 }
293
294 #[test]
295 fn download_url_generation() {
296 let fr = FileRef {
297 id: "123".into(),
298 sub: "f0001".into(),
299 name: "t.png".into(),
300 size: 0,
301 mime: "image/png".into(),
302 sha256: String::new(),
303 shard: None,
304 };
305 assert_eq!(
306 fr.download_url("http://localhost:2900", "default", "users"),
307 "http://localhost:2900/v1/files/default/users/f0001/123-t.png"
308 );
309 assert_eq!(fr.relative_url("default", "users"), "/v1/files/default/users/f0001/123-t.png");
310 }
311
312 #[test]
313 fn format_size_units() {
314 let mk = |size: u64| FileRef {
315 id: String::new(),
316 sub: String::new(),
317 name: String::new(),
318 size,
319 mime: String::new(),
320 sha256: String::new(),
321 shard: None,
322 };
323 assert_eq!(mk(0).format_size(), "0 B");
324 assert_eq!(mk(512).format_size(), "512 B");
325 assert_eq!(mk(1024).format_size(), "1.0 KB");
326 assert_eq!(mk(1_048_576).format_size(), "1.0 MB");
327 }
328
329 #[test]
330 fn stored_name_and_path() {
331 let fr = FileRef {
332 id: "42".into(),
333 sub: "f0001".into(),
334 name: "My Document.pdf".into(),
335 size: 100,
336 mime: "application/pdf".into(),
337 sha256: String::new(),
338 shard: None,
339 };
340 assert_eq!(fr.stored_name(), "42-my-document.pdf");
341 assert_eq!(fr.relative_path(), "f0001/42-my-document.pdf");
342 }
343
344 #[test]
345 fn stored_name_with_shard() {
346 let fr = FileRef {
347 id: "42".into(),
348 sub: "f0001".into(),
349 name: "test.png".into(),
350 size: 100,
351 mime: "image/png".into(),
352 sha256: String::new(),
353 shard: Some(3),
354 };
355 assert_eq!(fr.relative_path(), "shard-3/f0001/42-test.png");
356 }
357
358 #[test]
359 fn cell_as_file() {
360 use super::super::kalam_cell_value::KalamCellValue;
361
362 let cell = KalamCellValue::from(serde_json::json!({
364 "id": "1", "sub": "f0001", "name": "a.png",
365 "size": 10, "mime": "image/png", "sha256": "x"
366 }));
367 let fr = cell.as_file().unwrap();
368 assert_eq!(fr.id, "1");
369 assert!(fr.is_image());
370
371 assert!(KalamCellValue::text("Alice").as_file().is_none());
373
374 assert!(KalamCellValue::null().as_file().is_none());
376 }
377}