Skip to main content

ffbm/
import.rs

1//! Import bookmarks from file tree to database.
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::Path;
6
7use crate::db::{self, get_or_create_place_id, set_keyword};
8use crate::error::Result;
9use crate::types::{Bookmark, FolderIndex, Separator, dir_name_to_root};
10
11/// Position assigned to items not listed in the index.
12/// They appear after all indexed items.
13const UNLISTED_POSITION_BASE: i32 = 100_000;
14
15/// Characters used for GUID generation.
16const GUID_CHARS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-";
17
18/// Generate a Firefox-style GUID.
19fn generate_guid() -> String {
20    use std::time::{SystemTime, UNIX_EPOCH};
21
22    let timestamp = SystemTime::now()
23        .duration_since(UNIX_EPOCH)
24        .map_or(0, |d| d.as_nanos());
25
26    let mut guid = String::with_capacity(12);
27    let mut val = timestamp;
28    for _ in 0..12 {
29        guid.push(GUID_CHARS[(val % 64) as usize] as char);
30        val /= 64;
31    }
32    guid
33}
34
35/// A file tree entry that can be either a bookmark or separator.
36#[derive(Debug)]
37enum Entry {
38    Bookmark(Bookmark),
39    Separator(Separator),
40}
41
42/// A folder with its path and entries.
43type FolderData = Vec<(String, Vec<(String, Entry)>)>;
44
45/// Read a bookmark from a TOML file.
46fn read_bookmark(path: &Path) -> Result<Bookmark> {
47    let content = fs::read_to_string(path)?;
48    let bookmark: Bookmark = toml::from_str(&content)?;
49    Ok(bookmark)
50}
51
52/// Read a separator from a TOML file.
53fn read_separator(path: &Path) -> Result<Separator> {
54    let content = fs::read_to_string(path)?;
55    let separator: Separator = toml::from_str(&content)?;
56    Ok(separator)
57}
58
59/// Read the folder index if present.
60fn read_folder_index(dir: &Path) -> Option<FolderIndex> {
61    let index_path = dir.join("__index.toml");
62    if index_path.is_file() {
63        let content = fs::read_to_string(&index_path).ok()?;
64        toml::from_str(&content).ok()
65    } else {
66        None
67    }
68}
69
70/// Build a position map from a folder index.
71///
72/// Returns a map from item name to position. Folder names have their trailing
73/// `/` stripped. Items not in the index get positions starting at
74/// `UNLISTED_POSITION_BASE`.
75fn build_position_map(index: Option<&FolderIndex>) -> HashMap<String, i32> {
76    let mut map = HashMap::new();
77    if let Some(idx) = index {
78        for (i, name) in idx.order.iter().enumerate() {
79            // Strip trailing `/` from folder names
80            let key = name.strip_suffix('/').unwrap_or(name).to_string();
81            #[expect(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
82            map.insert(key, i as i32);
83        }
84    }
85    map
86}
87
88/// Get position for an item, using the index map or assigning an unlisted
89/// position.
90fn get_position(
91    name: &str,
92    position_map: &HashMap<String, i32>,
93    unlisted_counter: &mut i32,
94) -> i32 {
95    if let Some(&pos) = position_map.get(name) {
96        pos
97    } else {
98        let pos = UNLISTED_POSITION_BASE + *unlisted_counter;
99        *unlisted_counter += 1;
100        pos
101    }
102}
103
104/// Recursively read all entries from a directory.
105///
106/// Uses `__index.toml` if present for ordering, otherwise sorts alphabetically.
107fn read_directory_entries(dir: &Path) -> Result<Vec<(String, Entry)>> {
108    let mut entries = Vec::new();
109
110    if !dir.is_dir() {
111        return Ok(entries);
112    }
113
114    // Read all files first
115    let mut file_entries: Vec<(String, Entry)> = Vec::new();
116    for entry in fs::read_dir(dir)? {
117        let entry = entry?;
118        let path = entry.path();
119        let file_name = entry.file_name().to_string_lossy().to_string();
120
121        if path.is_file() {
122            // Skip index file
123            if file_name == "__index.toml" {
124                continue;
125            }
126
127            let ext = path.extension().and_then(|e| e.to_str());
128            if ext.is_some_and(|e| e.eq_ignore_ascii_case("toml")) {
129                let bookmark = read_bookmark(&path)?;
130                let name = file_name.strip_suffix(".toml").unwrap_or(&file_name);
131                file_entries.push((name.to_string(), Entry::Bookmark(bookmark)));
132            } else if ext.is_some_and(|e| e.eq_ignore_ascii_case("separator")) {
133                let separator = read_separator(&path)?;
134                let name = file_name.strip_suffix(".separator").unwrap_or(&file_name);
135                file_entries.push((name.to_string(), Entry::Separator(separator)));
136            }
137        }
138    }
139
140    // Read index if present
141    let index = read_folder_index(dir);
142
143    if let Some(idx) = index {
144        // Build name-to-position map from index order
145        let name_to_pos: HashMap<&str, usize> = idx
146            .order
147            .iter()
148            .enumerate()
149            .map(|(i, name)| (name.as_str(), i))
150            .collect();
151
152        // Separate indexed and unlisted entries
153        let mut indexed: Vec<(usize, (String, Entry))> = Vec::new();
154        let mut unlisted: Vec<(String, Entry)> = Vec::new();
155
156        for (name, entry) in file_entries {
157            if let Some(&pos) = name_to_pos.get(name.as_str()) {
158                indexed.push((pos, (name, entry)));
159            } else {
160                unlisted.push((name, entry));
161            }
162        }
163
164        // Sort indexed by position
165        indexed.sort_by_key(|(pos, _)| *pos);
166
167        // Sort unlisted alphabetically
168        unlisted.sort_by(|(a, _), (b, _)| a.cmp(b));
169
170        // Indexed items first, then unlisted
171        entries = indexed.into_iter().map(|(_, e)| e).collect();
172        entries.extend(unlisted);
173    } else {
174        // No index: sort alphabetically as fallback
175        file_entries.sort_by(|(a, _), (b, _)| a.cmp(b));
176        entries = file_entries;
177    }
178
179    Ok(entries)
180}
181
182/// Recursively read folder structure from a directory.
183///
184/// Returns folders ordered by index if present. Includes all directories,
185/// even empty ones, to maintain the folder hierarchy.
186fn read_folder_structure(dir: &Path, parent_path: &str) -> Result<FolderData> {
187    let mut folders = Vec::new();
188
189    if !dir.is_dir() {
190        return Ok(folders);
191    }
192
193    // Read entries in this directory
194    let entries = read_directory_entries(dir)?;
195    // Always include folders to maintain hierarchy (empty folders become
196    // parents for nested content)
197    folders.push((parent_path.to_string(), entries));
198
199    // Collect subdirectories
200    let mut subdirs: Vec<_> = fs::read_dir(dir)?
201        .filter_map(std::result::Result::ok)
202        .filter(|e| e.path().is_dir())
203        .collect();
204
205    // Read index for ordering subdirectories
206    let index = read_folder_index(dir);
207
208    if let Some(idx) = index {
209        // Build name-to-position map for folders (entries ending with /)
210        let name_to_pos: HashMap<&str, usize> = idx
211            .order
212            .iter()
213            .enumerate()
214            .filter_map(|(i, name)| name.strip_suffix('/').map(|n| (n, i)))
215            .collect();
216
217        // Separate indexed and unlisted subdirs
218        let mut indexed: Vec<(usize, std::fs::DirEntry)> = Vec::new();
219        let mut unlisted: Vec<std::fs::DirEntry> = Vec::new();
220
221        for subdir in subdirs {
222            let name = subdir.file_name().to_string_lossy().to_string();
223            if let Some(&pos) = name_to_pos.get(name.as_str()) {
224                indexed.push((pos, subdir));
225            } else {
226                unlisted.push(subdir);
227            }
228        }
229
230        // Sort indexed by position
231        indexed.sort_by_key(|(pos, _)| *pos);
232
233        // Sort unlisted alphabetically
234        unlisted.sort_by_key(std::fs::DirEntry::file_name);
235
236        // Recombine
237        subdirs = indexed.into_iter().map(|(_, e)| e).collect();
238        subdirs.extend(unlisted);
239    } else {
240        // No index: sort subdirectories by name for consistent ordering
241        subdirs.sort_by_key(std::fs::DirEntry::file_name);
242    }
243
244    for subdir in subdirs {
245        let subdir_name = subdir.file_name().to_string_lossy().to_string();
246        let subdir_path = if parent_path.is_empty() {
247            subdir_name.clone()
248        } else {
249            format!("{parent_path}/{subdir_name}")
250        };
251        let sub_folders = read_folder_structure(&subdir.path(), &subdir_path)?;
252        folders.extend(sub_folders);
253    }
254
255    Ok(folders)
256}
257
258/// Import bookmarks from file tree to database.
259///
260/// This performs a full rebuild: deletes all existing bookmarks and re-imports.
261///
262/// # Errors
263///
264/// Returns an error if the database cannot be modified or files cannot be read.
265#[expect(clippy::too_many_lines)]
266pub fn import_bookmarks(db_path: &Path, import_dir: &Path) -> Result<ImportStats> {
267    let mut conn = db::open_readwrite(db_path)?;
268    let tx = conn.transaction()?;
269
270    let mut stats = ImportStats::default();
271
272    // Delete all existing bookmarks except roots
273    db::delete_all_bookmarks(&tx)?;
274
275    // Map from folder path to database ID
276    let mut folder_ids: HashMap<String, i64> = HashMap::new();
277
278    // Cache of position maps per directory path
279    let mut position_maps: HashMap<String, HashMap<String, i32>> = HashMap::new();
280
281    // Process each root directory
282    for root_name in ["menu", "toolbar", "other", "mobile"] {
283        let root_dir = import_dir.join(root_name);
284        if !root_dir.exists() {
285            continue;
286        }
287
288        let Some(root_id) = dir_name_to_root(root_name) else {
289            continue;
290        };
291
292        // Read folder structure
293        let folder_data = read_folder_structure(&root_dir, "")?;
294
295        // Build position maps for all directories
296        for (folder_path, _) in &folder_data {
297            let dir_path = if folder_path.is_empty() {
298                root_dir.clone()
299            } else {
300                root_dir.join(folder_path)
301            };
302            let index = read_folder_index(&dir_path);
303            let key = if folder_path.is_empty() {
304                root_name.to_string()
305            } else {
306                format!("{root_name}/{folder_path}")
307            };
308            position_maps.insert(key, build_position_map(index.as_ref()));
309        }
310
311        // Track unlisted item counters per parent
312        let mut unlisted_counters: HashMap<i64, i32> = HashMap::new();
313
314        // First pass: create all folders with positions from parent's index
315        for (folder_path, _) in &folder_data {
316            if folder_path.is_empty() {
317                // Root level, use root_id directly
318                folder_ids.insert(root_name.to_string(), root_id);
319                continue;
320            }
321
322            let parts: Vec<&str> = folder_path.split('/').collect();
323            let folder_name = parts.last().copied().unwrap_or("");
324
325            // Find parent folder ID and its position map
326            let (parent_id, parent_key) = if parts.len() == 1 {
327                (root_id, root_name.to_string())
328            } else {
329                let parent_path = format!("{root_name}/{}", parts[..parts.len() - 1].join("/"));
330                let pid = *folder_ids.get(&parent_path).unwrap_or(&root_id);
331                (pid, parent_path)
332            };
333
334            // Get position from parent's index
335            let position_map = position_maps.get(&parent_key).cloned().unwrap_or_default();
336            let unlisted = unlisted_counters.entry(parent_id).or_insert(0);
337            let position = get_position(folder_name, &position_map, unlisted);
338
339            #[expect(clippy::cast_possible_truncation)]
340            let now = std::time::SystemTime::now()
341                .duration_since(std::time::UNIX_EPOCH)
342                .map_or(0, |d| d.as_micros() as i64);
343
344            // Generate a GUID for the folder
345            let guid = generate_guid();
346
347            let folder_id =
348                db::insert_folder(&tx, parent_id, folder_name, position, &guid, now, now)?;
349
350            let full_path = format!("{root_name}/{folder_path}");
351            folder_ids.insert(full_path, folder_id);
352            stats.folders_created += 1;
353        }
354
355        // Second pass: create bookmarks and separators with positions from index
356        for (folder_path, entries) in &folder_data {
357            let (parent_id, parent_key) = if folder_path.is_empty() {
358                (root_id, root_name.to_string())
359            } else {
360                let full_path = format!("{root_name}/{folder_path}");
361                let pid = *folder_ids.get(&full_path).unwrap_or(&root_id);
362                (pid, full_path)
363            };
364
365            // Get position map for this folder
366            let position_map = position_maps.get(&parent_key).cloned().unwrap_or_default();
367            let unlisted = unlisted_counters.entry(parent_id).or_insert(0);
368
369            for (name, entry) in entries {
370                let position = get_position(name, &position_map, unlisted);
371
372                match entry {
373                    Entry::Bookmark(bm) => {
374                        // Get or create place_id for the URL
375                        let place_id = get_or_create_place_id(&tx, &bm.url)?;
376
377                        // Insert bookmark with position from index
378                        db::insert_bookmark(
379                            &tx,
380                            parent_id,
381                            place_id,
382                            &bm.title,
383                            position,
384                            &bm.guid,
385                            bm.date_added,
386                            bm.last_modified,
387                        )?;
388
389                        // Set keyword if present
390                        if !bm.keyword.is_empty() {
391                            set_keyword(&tx, place_id, &bm.keyword)?;
392                        }
393
394                        // Add tags
395                        for tag in &bm.tags {
396                            db::add_tag(&tx, place_id, tag)?;
397                        }
398
399                        stats.bookmarks_imported += 1;
400                    }
401                    Entry::Separator(sep) => {
402                        db::insert_separator(
403                            &tx,
404                            parent_id,
405                            position,
406                            &sep.guid,
407                            sep.date_added,
408                            sep.last_modified,
409                        )?;
410                        stats.separators_imported += 1;
411                    }
412                }
413            }
414        }
415    }
416
417    tx.commit()?;
418
419    // Vacuum the database to reclaim space
420    conn.execute("VACUUM", [])?;
421
422    Ok(stats)
423}
424
425/// Statistics from an import operation.
426#[derive(Debug, Default)]
427pub struct ImportStats {
428    /// Number of folders created.
429    pub folders_created: usize,
430    /// Number of bookmarks imported.
431    pub bookmarks_imported: usize,
432    /// Number of separators imported.
433    pub separators_imported: usize,
434}