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