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::{root_dir_name, roots, Folder, FolderIndex};
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#[allow(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(
144            "SELECT id, position FROM moz_bookmarks WHERE type = 2",
145        )?;
146        stmt.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i32>(1)?)))?
147            .filter_map(std::result::Result::ok)
148            .collect()
149    };
150
151    // Create directories for folders and track them in parent's index
152    for folder in &sorted_folders {
153        let root = find_root_folder(folder.id, &folder_tree);
154        if let Some(root_name) = root_dir_name(root) {
155            let path = build_folder_path(folder.id, &folder_tree, &folder_titles);
156            let mut dir_path = export_dir.join(root_name);
157            for segment in &path {
158                dir_path = dir_path.join(sanitize_filename(segment));
159            }
160            if !dir_path.exists() {
161                fs::create_dir_all(&dir_path)?;
162                stats.folders_created += 1;
163            }
164
165            // Add folder to parent's index
166            if let Some(parent_dir) = dir_path.parent() {
167                let folder_name = sanitize_filename(&folder.title);
168                let position = folder_positions.get(&folder.id).copied().unwrap_or(0);
169                folder_indices
170                    .entry(parent_dir.to_path_buf())
171                    .or_default()
172                    .push(IndexItem {
173                        name: format!("{folder_name}/"),
174                        position,
175                    });
176            }
177
178            created_dirs.insert(dir_path);
179        }
180    }
181
182    // Export bookmarks
183    for db_bm in &bookmarks {
184        // Skip tag entries (bookmarks under tags root)
185        if db_bm.parent == roots::TAGS {
186            continue;
187        }
188        if let Some(parent_folder) = folder_map.get(&db_bm.parent)
189            && parent_folder.parent == roots::TAGS
190        {
191            continue;
192        }
193
194        let root = find_root_folder(db_bm.parent, &folder_tree);
195        let Some(root_name) = root_dir_name(root) else {
196            continue;
197        };
198
199        // Build directory path
200        let mut dir_path = export_dir.join(root_name);
201        if db_bm.parent != root {
202            let path = build_folder_path(db_bm.parent, &folder_tree, &folder_titles);
203            for segment in &path {
204                dir_path = dir_path.join(sanitize_filename(segment));
205            }
206        }
207
208        // Ensure directory exists
209        if !dir_path.exists() {
210            fs::create_dir_all(&dir_path)?;
211        }
212
213        // Convert to export format
214        let Some(bookmark) = db_bookmark_to_export(&conn, db_bm)? else {
215            continue;
216        };
217
218        // Check if base file already exists from previous export (preserve user
219        // comments)
220        let base_name = sanitize_filename(&bookmark.title);
221        let base_path = dir_path.join(format!("{base_name}.toml"));
222        if pre_existing.contains(&base_path) {
223            stats.bookmarks_skipped += 1;
224            // Still add to index with original filename
225            folder_indices
226                .entry(dir_path.clone())
227                .or_default()
228                .push(IndexItem {
229                    name: base_name,
230                    position: db_bm.position,
231                });
232            continue;
233        }
234
235        // Generate unique filename for same-title collisions within this export
236        let (file_path, index_name) = {
237            let mut path = base_path;
238            let mut name = base_name.clone();
239            let mut counter = 1;
240            while path.exists() || written_this_session.contains(&path) {
241                name = format!("{base_name}-{counter}");
242                path = dir_path.join(format!("{name}.toml"));
243                counter += 1;
244            }
245            (path, name)
246        };
247
248        // Write TOML file
249        let toml_content = toml::to_string_pretty(&bookmark)?;
250        fs::write(&file_path, toml_content)?;
251        written_this_session.insert(file_path);
252        stats.bookmarks_exported += 1;
253
254        // Add to folder index
255        folder_indices
256            .entry(dir_path)
257            .or_default()
258            .push(IndexItem {
259                name: index_name,
260                position: db_bm.position,
261            });
262    }
263
264    // Export separators
265    for db_sep in &separators {
266        let root = find_root_folder(db_sep.parent, &folder_tree);
267        let Some(root_name) = root_dir_name(root) else {
268            continue;
269        };
270
271        // Build directory path
272        let mut dir_path = export_dir.join(root_name);
273        if db_sep.parent != root {
274            let path = build_folder_path(db_sep.parent, &folder_tree, &folder_titles);
275            for segment in &path {
276                dir_path = dir_path.join(sanitize_filename(segment));
277            }
278        }
279
280        // Ensure directory exists
281        if !dir_path.exists() {
282            fs::create_dir_all(&dir_path)?;
283        }
284
285        let separator = db_separator_to_export(db_sep);
286        // Use GUID instead of position for filename
287        let base_name = format!("---{}", separator.guid);
288        let base_path = dir_path.join(format!("{base_name}.separator"));
289        if pre_existing.contains(&base_path) {
290            stats.separators_skipped += 1;
291            // Still add to index
292            folder_indices
293                .entry(dir_path.clone())
294                .or_default()
295                .push(IndexItem {
296                    name: base_name,
297                    position: db_sep.position,
298                });
299            continue;
300        }
301
302        let (file_path, index_name) = {
303            let mut path = base_path;
304            let mut name = base_name.clone();
305            let mut counter = 1;
306            while path.exists() || written_this_session.contains(&path) {
307                name = format!("{base_name}-{counter}");
308                path = dir_path.join(format!("{name}.separator"));
309                counter += 1;
310            }
311            (path, name)
312        };
313
314        let toml_content = toml::to_string_pretty(&separator)?;
315        fs::write(&file_path, toml_content)?;
316        written_this_session.insert(file_path);
317        stats.separators_exported += 1;
318
319        // Add to folder index
320        folder_indices
321            .entry(dir_path)
322            .or_default()
323            .push(IndexItem {
324                name: index_name,
325                position: db_sep.position,
326            });
327    }
328
329    // Write __index.toml for each folder with content
330    for (dir_path, mut items) in folder_indices {
331        // Sort by original DB position
332        items.sort_by_key(|item| item.position);
333
334        let index = FolderIndex {
335            order: items.into_iter().map(|item| item.name).collect(),
336        };
337
338        let index_path = dir_path.join("__index.toml");
339        let toml_content = toml::to_string_pretty(&index)?;
340        fs::write(index_path, toml_content)?;
341        stats.indexes_written += 1;
342    }
343
344    Ok(stats)
345}
346
347/// Statistics from an export operation.
348#[derive(Debug, Default)]
349pub struct ExportStats {
350    /// Number of folders created.
351    pub folders_created: usize,
352    /// Number of bookmarks exported.
353    pub bookmarks_exported: usize,
354    /// Number of bookmarks skipped (already exist).
355    pub bookmarks_skipped: usize,
356    /// Number of separators exported.
357    pub separators_exported: usize,
358    /// Number of separators skipped (already exist).
359    pub separators_skipped: usize,
360    /// Number of __index.toml files written.
361    pub indexes_written: usize,
362}