use serde::Serialize; use std::collections::{BTreeSet, HashMap}; use std::path::{Path, PathBuf}; use tauri::AppHandle; use crate::db::Store; use crate::workspace::workspace_root; pub const ASSETS_DIR: &str = ".assets"; const FONT_EXTENSIONS: [&str; 4] = ["ttf", "otf", "ttc", "otc"]; const IMAGE_EXTENSIONS: [&str; 6] = ["png", "jpg", "jpeg", "gif", "svg", "webp"]; fn extension_of(name: &str) -> String { Path::new(name) .extension() .and_then(|ext| ext.to_str()) .unwrap_or("") .to_lowercase() } pub fn is_font(name: &str) -> bool { FONT_EXTENSIONS.contains(&extension_of(name).as_str()) } pub fn is_image(name: &str) -> bool { IMAGE_EXTENSIONS.contains(&extension_of(name).as_str()) } pub fn assets_dir(app: &AppHandle, store: &Store) -> Result { let dir = workspace_root(app, store)?.join(ASSETS_DIR); std::fs::create_dir_all(&dir) .map_err(|e| format!("Cannot create assets folder: {}", e))?; Ok(dir) } pub fn families_in(data: &[u8]) -> Vec { let mut families = BTreeSet::new(); for font in typst::text::Font::iter(typst::foundations::Bytes::new(data.to_vec())) { families.insert(font.info().family.clone()); } families.into_iter().collect() } #[derive(Serialize)] pub struct Asset { pub name: String, pub kind: String, pub size: u64, pub font_families: Vec, } pub fn list_assets(app: &AppHandle, store: &Store) -> Result, String> { let dir = assets_dir(app, store)?; let mut assets = Vec::new(); for entry in std::fs::read_dir(&dir).map_err(|e| e.to_string())? { let entry = entry.map_err(|e| e.to_string())?; if !entry.path().is_file() { continue; } let name = entry.file_name().to_string_lossy().to_string(); if name.starts_with('.') { continue; } let size = entry.metadata().map(|m| m.len()).unwrap_or(0); let font_families = if is_font(&name) { std::fs::read(entry.path()) .map(|data| families_in(&data)) .unwrap_or_default() } else { Vec::new() }; assets.push(Asset { kind: if is_font(&name) { "font" } else if is_image(&name) { "image" } else { "file" } .to_string(), name, size, font_families, }); } assets.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); Ok(assets) } pub fn font_families(files: &HashMap>) -> Vec { let mut families = BTreeSet::new(); for data in typst_assets::fonts() { for font in typst::text::Font::iter(typst::foundations::Bytes::new(data)) { families.insert(font.info().family.clone()); } } for (name, data) in files { if is_font(name) { for family in families_in(data) { families.insert(family); } } } families.into_iter().collect() } fn unique_destination(dir: &Path, name: &str) -> PathBuf { let candidate = dir.join(name); if !candidate.exists() { return candidate; } let stem = Path::new(name) .file_stem() .and_then(|s| s.to_str()) .unwrap_or("file") .to_string(); let extension = Path::new(name) .extension() .and_then(|e| e.to_str()) .map(|e| format!(".{}", e)) .unwrap_or_default(); for index in 2..1000 { let candidate = dir.join(format!("{}-{}{}", stem, index, extension)); if !candidate.exists() { return candidate; } } dir.join(name) } fn copy_tree(source: &Path, destination: &Path) -> Result<(), String> { std::fs::create_dir_all(destination).map_err(|e| e.to_string())?; for entry in std::fs::read_dir(source).map_err(|e| e.to_string())? { let entry = entry.map_err(|e| e.to_string())?; let name = entry.file_name(); let from = entry.path(); let to = destination.join(&name); if from.is_dir() { copy_tree(&from, &to)?; } else { std::fs::copy(&from, &to).map_err(|e| e.to_string())?; } } Ok(()) } pub fn import_paths(sources: &[String], destination: &Path) -> Result, String> { std::fs::create_dir_all(destination).map_err(|e| e.to_string())?; let mut imported = Vec::new(); for source in sources { let source_path = Path::new(source); let name = source_path .file_name() .map(|n| n.to_string_lossy().to_string()) .ok_or_else(|| format!("'{}' has no name", source))?; if source_path.is_dir() { let target = unique_destination(destination, &name); copy_tree(source_path, &target)?; } else if source_path.is_file() { let target = unique_destination(destination, &name); std::fs::copy(source_path, &target) .map_err(|e| format!("Could not import '{}': {}", name, e))?; } else { return Err(format!("'{}' could not be read", source)); } imported.push(name); } Ok(imported) } pub fn import_files(sources: &[String], destination: &Path) -> Result, String> { std::fs::create_dir_all(destination).map_err(|e| e.to_string())?; let mut imported = Vec::new(); for source in sources { let source_path = Path::new(source); if !source_path.is_file() { return Err(format!("'{}' is not a file", source)); } let name = source_path .file_name() .map(|n| n.to_string_lossy().to_string()) .ok_or_else(|| format!("'{}' has no file name", source))?; let target = unique_destination(destination, &name); std::fs::copy(source_path, &target) .map_err(|e| format!("Could not import '{}': {}", name, e))?; imported.push( target .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or(name), ); } Ok(imported) } pub fn delete_asset(app: &AppHandle, store: &Store, name: &str) -> Result<(), String> { if name.contains('/') || name.contains('\\') || name.contains("..") { return Err("Invalid asset name".to_string()); } let path = assets_dir(app, store)?.join(name); std::fs::remove_file(path).map_err(|e| e.to_string()) } pub fn asset_files(app: &AppHandle, store: &Store) -> HashMap> { let Ok(dir) = assets_dir(app, store) else { return HashMap::new(); }; let mut files = HashMap::new(); let Ok(entries) = std::fs::read_dir(&dir) else { return files; }; for entry in entries.filter_map(|entry| entry.ok()) { if !entry.path().is_file() { continue; } let name = entry.file_name().to_string_lossy().to_string(); if name.starts_with('.') { continue; } if let Some(data) = crate::workspace::read_file_cached(&entry.path()) { files.insert(name, data); } } files }