ffbm/
export.rs

1//! Export bookmarks from database to file tree.
2
3use std::collections::{HashMap, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::db::{
8    self, build_folder_path, build_folder_titles, build_folder_tree, db_bookmark_to_export,
9    db_separator_to_export,
10};
11use crate::error::Result;
12use crate::types::{Folder, FolderIndex, root_dir_name, roots};
13
14/// Sanitize a title for use as a filename.
15///
16/// Replaces problematic characters and limits length.
17fn sanitize_filename(title: &str) -> String {
18    let sanitized: String = title
19        .chars()
20        .map(|c| match c {
21            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0'..='\x1f' => '_',
22            _ => c,
23        })
24        .collect();
25
26    // Trim whitespace and limit length (respecting char boundaries)
27    let trimmed = sanitized.trim();
28    if trimmed.len() > 100 {
29        trimmed
30            .char_indices()
31            .take_while(|(i, _)| *i < 100)
32            .map(|(_, c)| c)
33            .collect()
34    } else if trimmed.is_empty() {
35        "untitled".to_string()
36    } else {
37        trimmed.to_string()
38    }
39}
40
41/// Determine the root folder for a given folder ID.
42fn find_root_folder(folder_id: i64, folder_tree: &HashMap<i64, i64>) -> i64 {
43    let mut current = folder_id;
44    while let Some(&parent) = folder_tree.get(&current) {
45        if parent == roots::ROOT {
46            return current;
47        }
48        current = parent;
49    }
50    folder_id
51}
52
53/// Calculate folder depth for sorting.
54fn folder_depth(f: &Folder, tree: &HashMap<i64, i64>) -> usize {
55    let mut depth = 0;
56    let mut current = f.parent;
57    while let Some(&parent) = tree.get(&current) {
58        depth += 1;
59        if current <= roots::MOBILE {
60            break;
61        }
62        current = parent;
63    }
64    depth
65}
66
67/// An item to be recorded in the folder index.
68struct IndexItem {
69    /// The name to write in the index (sans extension for bookmarks, with / for
70    /// folders, ---{guid} for separators).
71    name: String,
72    /// Original position from database for sorting.
73    position: i32,
74}
75
76/// Export bookmarks from database to file tree.
77///
78/// Skips existing TOML files to preserve user comments.
79///
80/// # Errors
81///
82/// Returns an error if the database cannot be read or files cannot be written.
83#[expect(clippy::too_many_lines)]
84pub fn export_bookmarks(db_path: &Path, export_dir: &Path) -> Result<ExportStats> {
85    let conn = db::open_readonly(db_path)?;
86
87    let folders = db::read_folders(&conn)?;
88    let bookmarks = db::read_bookmarks(&conn)?;
89    let separators = db::read_separators(&conn)?;
90
91    let folder_tree = build_folder_tree(&folders);
92    let folder_titles = build_folder_titles(&folders);
93
94    // Build folder ID to Folder map
95    let folder_map: HashMap<i64, &Folder> = folders.iter().map(|f| (f.id, f)).collect();
96
97    let mut stats = ExportStats::default();
98
99    // Track created directories for separator and bookmark naming
100    let mut created_dirs: HashSet<PathBuf> = HashSet::new();
101
102    // Collect pre-existing files to skip (preserve user comments)
103    let pre_existing: HashSet<PathBuf> = if export_dir.exists() {
104        walkdir::WalkDir::new(export_dir)
105            .into_iter()
106            .filter_map(std::result::Result::ok)
107            .filter(|e| e.file_type().is_file())
108            .map(|e| e.path().to_path_buf())
109            .collect()
110    } else {
111        HashSet::new()
112    };
113
114    // Track files written in this session (for unique_filename)
115    let mut written_this_session: HashSet<PathBuf> = HashSet::new();
116
117    // Track index items per directory: dir_path -> Vec<IndexItem>
118    let mut folder_indices: HashMap<PathBuf, Vec<IndexItem>> = HashMap::new();
119
120    // Create root directories
121    for root_id in [roots::MENU, roots::TOOLBAR, roots::OTHER, roots::MOBILE] {
122        if let Some(dir_name) = root_dir_name(root_id) {
123            let root_path = export_dir.join(dir_name);
124            if !root_path.exists() {
125                fs::create_dir_all(&root_path)?;
126            }
127            created_dirs.insert(root_path);
128        }
129    }
130
131    // Create folder structure
132    // Sort folders by depth to ensure parents are created first
133    let mut sorted_folders: Vec<&Folder> = folders
134        .iter()
135        .filter(|f| f.id > roots::MOBILE && f.parent != roots::TAGS)
136        .collect();
137
138    sorted_folders.sort_by_key(|f| folder_depth(f, &folder_tree));
139
140    // Build map from folder ID to its position within parent (from DB)
141    // We need to query positions for folders
142    let folder_positions: HashMap<i64, i32> = {
143        let mut stmt = conn.prepare("SELECT id, position FROM moz_bookmarks WHERE type = 2")?;
144        stmt.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i32>(1)?)))?
145            .filter_map(std::result::Result::ok)
146            .collect()
147    };
148
149    // Create directories for folders and track them in parent's index
150    for folder in &sorted_folders {
151        let root = find_root_folder(folder.id, &folder_tree);
152        if let Some(root_name) = root_dir_name(root) {
153            let path = build_folder_path(folder.id, &folder_tree, &folder_titles);
154            let mut dir_path = export_dir.join(root_name);
155            for segment in &path {
156                dir_path = dir_path.join(sanitize_filename(segment));
157            }
158            if !dir_path.exists() {
159                fs::create_dir_all(&dir_path)?;
160                stats.folders_created += 1;
161            }
162
163            // Add folder to parent's index
164            if let Some(parent_dir) = dir_path.parent() {
165                let folder_name = sanitize_filename(&folder.title);
166                let position = folder_positions.get(&folder.id).copied().unwrap_or(0);
167                folder_indices
168                    .entry(parent_dir.to_path_buf())
169                    .or_default()
170                    .push(IndexItem {
171                        name: format!("{folder_name}/"),
172                        position,
173                    });
174            }
175
176            created_dirs.insert(dir_path);
177        }
178    }
179
180    // Export bookmarks
181    for db_bm in &bookmarks {
182        // Skip tag entries (bookmarks under tags root)
183        if db_bm.parent == roots::TAGS {
184            continue;
185        }
186        if let Some(parent_folder) = folder_map.get(&db_bm.parent)
187            && parent_folder.parent == roots::TAGS
188        {
189            continue;
190        }
191
192        let root = find_root_folder(db_bm.parent, &folder_tree);
193        let Some(root_name) = root_dir_name(root) else {
194            continue;
195        };
196
197        // Build directory path
198        let mut dir_path = export_dir.join(root_name);
199        if db_bm.parent != root {
200            let path = build_folder_path(db_bm.parent, &folder_tree, &folder_titles);
201            for segment in &path {
202                dir_path = dir_path.join(sanitize_filename(segment));
203            }
204        }
205
206        // Ensure directory exists
207        if !dir_path.exists() {
208            fs::create_dir_all(&dir_path)?;
209        }
210
211        // Convert to export format
212        let Some(bookmark) = db_bookmark_to_export(&conn, db_bm)? else {
213            continue;
214        };
215
216        // Check if base file already exists from previous export (preserve user
217        // comments)
218        let base_name = sanitize_filename(&bookmark.title);
219        let base_path = dir_path.join(format!("{base_name}.toml"));
220        if pre_existing.contains(&base_path) {
221            stats.bookmarks_skipped += 1;
222            // Still add to index with original filename
223            folder_indices
224                .entry(dir_path.clone())
225                .or_default()
226                .push(IndexItem {
227                    name: base_name,
228                    position: db_bm.position,
229                });
230            continue;
231        }
232
233        // Generate unique filename for same-title collisions within this export
234        let (file_path, index_name) = {
235            let mut path = base_path;
236            let mut name = base_name.clone();
237            let mut counter = 1;
238            while path.exists() || written_this_session.contains(&path) {
239                name = format!("{base_name}-{counter}");
240                path = dir_path.join(format!("{name}.toml"));
241                counter += 1;
242            }
243            (path, name)
244        };
245
246        // Write TOML file
247        let toml_content = toml::to_string_pretty(&bookmark)?;
248        fs::write(&file_path, toml_content)?;
249        written_this_session.insert(file_path);
250        stats.bookmarks_exported += 1;
251
252        // Add to folder index
253        folder_indices.entry(dir_path).or_default().push(IndexItem {
254            name: index_name,
255            position: db_bm.position,
256        });
257    }
258
259    // Export separators
260    for db_sep in &separators {
261        let root = find_root_folder(db_sep.parent, &folder_tree);
262        let Some(root_name) = root_dir_name(root) else {
263            continue;
264        };
265
266        // Build directory path
267        let mut dir_path = export_dir.join(root_name);
268        if db_sep.parent != root {
269            let path = build_folder_path(db_sep.parent, &folder_tree, &folder_titles);
270            for segment in &path {
271                dir_path = dir_path.join(sanitize_filename(segment));
272            }
273        }
274
275        // Ensure directory exists
276        if !dir_path.exists() {
277            fs::create_dir_all(&dir_path)?;
278        }
279
280        let separator = db_separator_to_export(db_sep);
281        // Use GUID instead of position for filename
282        let base_name = format!("---{}", separator.guid);
283        let base_path = dir_path.join(format!("{base_name}.separator"));
284        if pre_existing.contains(&base_path) {
285            stats.separators_skipped += 1;
286            // Still add to index
287            folder_indices
288                .entry(dir_path.clone())
289                .or_default()
290                .push(IndexItem {
291                    name: base_name,
292                    position: db_sep.position,
293                });
294            continue;
295        }
296
297        let (file_path, index_name) = {
298            let mut path = base_path;
299            let mut name = base_name.clone();
300            let mut counter = 1;
301            while path.exists() || written_this_session.contains(&path) {
302                name = format!("{base_name}-{counter}");
303                path = dir_path.join(format!("{name}.separator"));
304                counter += 1;
305            }
306            (path, name)
307        };
308
309        let toml_content = toml::to_string_pretty(&separator)?;
310        fs::write(&file_path, toml_content)?;
311        written_this_session.insert(file_path);
312        stats.separators_exported += 1;
313
314        // Add to folder index
315        folder_indices.entry(dir_path).or_default().push(IndexItem {
316            name: index_name,
317            position: db_sep.position,
318        });
319    }
320
321    // Write __index.toml for each folder with content
322    for (dir_path, mut items) in folder_indices {
323        // Sort by original DB position
324        items.sort_by_key(|item| item.position);
325
326        let index = FolderIndex {
327            order: items.into_iter().map(|item| item.name).collect(),
328        };
329
330        let index_path = dir_path.join("__index.toml");
331        let toml_content = toml::to_string_pretty(&index)?;
332        fs::write(index_path, toml_content)?;
333        stats.indexes_written += 1;
334    }
335
336    Ok(stats)
337}
338
339/// Statistics from an export operation.
340#[derive(Debug, Default)]
341pub struct ExportStats {
342    /// Number of folders created.
343    pub folders_created: usize,
344    /// Number of bookmarks exported.
345    pub bookmarks_exported: usize,
346    /// Number of bookmarks skipped (already exist).
347    pub bookmarks_skipped: usize,
348    /// Number of separators exported.
349    pub separators_exported: usize,
350    /// Number of separators skipped (already exist).
351    pub separators_skipped: usize,
352    /// Number of __index.toml files written.
353    pub indexes_written: usize,
354}