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::{dir_name_to_root, Bookmark, FolderIndex, Separator};
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            #[allow(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)| {
216                name.strip_suffix('/').map(|n| (n, i))
217            })
218            .collect();
219
220        // Separate indexed and unlisted subdirs
221        let mut indexed: Vec<(usize, std::fs::DirEntry)> = Vec::new();
222        let mut unlisted: Vec<std::fs::DirEntry> = Vec::new();
223
224        for subdir in subdirs {
225            let name = subdir.file_name().to_string_lossy().to_string();
226            if let Some(&pos) = name_to_pos.get(name.as_str()) {
227                indexed.push((pos, subdir));
228            } else {
229                unlisted.push(subdir);
230            }
231        }
232
233        // Sort indexed by position
234        indexed.sort_by_key(|(pos, _)| *pos);
235
236        // Sort unlisted alphabetically
237        unlisted.sort_by_key(std::fs::DirEntry::file_name);
238
239        // Recombine
240        subdirs = indexed.into_iter().map(|(_, e)| e).collect();
241        subdirs.extend(unlisted);
242    } else {
243        // No index: sort subdirectories by name for consistent ordering
244        subdirs.sort_by_key(std::fs::DirEntry::file_name);
245    }
246
247    for subdir in subdirs {
248        let subdir_name = subdir.file_name().to_string_lossy().to_string();
249        let subdir_path = if parent_path.is_empty() {
250            subdir_name.clone()
251        } else {
252            format!("{parent_path}/{subdir_name}")
253        };
254        let sub_folders = read_folder_structure(&subdir.path(), &subdir_path)?;
255        folders.extend(sub_folders);
256    }
257
258    Ok(folders)
259}
260
261/// Import bookmarks from file tree to database.
262///
263/// This performs a full rebuild: deletes all existing bookmarks and re-imports.
264///
265/// # Errors
266///
267/// Returns an error if the database cannot be modified or files cannot be read.
268#[allow(clippy::too_many_lines)]
269pub fn import_bookmarks(db_path: &Path, import_dir: &Path) -> Result<ImportStats> {
270    let mut conn = db::open_readwrite(db_path)?;
271    let tx = conn.transaction()?;
272
273    let mut stats = ImportStats::default();
274
275    // Delete all existing bookmarks except roots
276    db::delete_all_bookmarks(&tx)?;
277
278    // Map from folder path to database ID
279    let mut folder_ids: HashMap<String, i64> = HashMap::new();
280
281    // Cache of position maps per directory path
282    let mut position_maps: HashMap<String, HashMap<String, i32>> = HashMap::new();
283
284    // Process each root directory
285    for root_name in ["menu", "toolbar", "other", "mobile"] {
286        let root_dir = import_dir.join(root_name);
287        if !root_dir.exists() {
288            continue;
289        }
290
291        let Some(root_id) = dir_name_to_root(root_name) else {
292            continue;
293        };
294
295        // Read folder structure
296        let folder_data = read_folder_structure(&root_dir, "")?;
297
298        // Build position maps for all directories
299        for (folder_path, _) in &folder_data {
300            let dir_path = if folder_path.is_empty() {
301                root_dir.clone()
302            } else {
303                root_dir.join(folder_path)
304            };
305            let index = read_folder_index(&dir_path);
306            let key = if folder_path.is_empty() {
307                root_name.to_string()
308            } else {
309                format!("{root_name}/{folder_path}")
310            };
311            position_maps.insert(key, build_position_map(index.as_ref()));
312        }
313
314        // Track unlisted item counters per parent
315        let mut unlisted_counters: HashMap<i64, i32> = HashMap::new();
316
317        // First pass: create all folders with positions from parent's index
318        for (folder_path, _) in &folder_data {
319            if folder_path.is_empty() {
320                // Root level, use root_id directly
321                folder_ids.insert(root_name.to_string(), root_id);
322                continue;
323            }
324
325            let parts: Vec<&str> = folder_path.split('/').collect();
326            let folder_name = parts.last().copied().unwrap_or("");
327
328            // Find parent folder ID and its position map
329            let (parent_id, parent_key) = if parts.len() == 1 {
330                (root_id, root_name.to_string())
331            } else {
332                let parent_path =
333                    format!("{root_name}/{}", parts[..parts.len() - 1].join("/"));
334                let pid = *folder_ids.get(&parent_path).unwrap_or(&root_id);
335                (pid, parent_path)
336            };
337
338            // Get position from parent's index
339            let position_map = position_maps.get(&parent_key).cloned().unwrap_or_default();
340            let unlisted = unlisted_counters.entry(parent_id).or_insert(0);
341            let position = get_position(folder_name, &position_map, unlisted);
342
343            #[allow(clippy::cast_possible_truncation)]
344            let now = std::time::SystemTime::now()
345                .duration_since(std::time::UNIX_EPOCH)
346                .map(|d| d.as_micros() as i64)
347                .unwrap_or(0);
348
349            // Generate a GUID for the folder
350            let guid = generate_guid();
351
352            let folder_id = db::insert_folder(
353                &tx,
354                parent_id,
355                folder_name,
356                position,
357                &guid,
358                now,
359                now,
360            )?;
361
362            let full_path = format!("{root_name}/{folder_path}");
363            folder_ids.insert(full_path, folder_id);
364            stats.folders_created += 1;
365        }
366
367        // Second pass: create bookmarks and separators with positions from index
368        for (folder_path, entries) in &folder_data {
369            let (parent_id, parent_key) = if folder_path.is_empty() {
370                (root_id, root_name.to_string())
371            } else {
372                let full_path = format!("{root_name}/{folder_path}");
373                let pid = *folder_ids.get(&full_path).unwrap_or(&root_id);
374                (pid, full_path)
375            };
376
377            // Get position map for this folder
378            let position_map = position_maps.get(&parent_key).cloned().unwrap_or_default();
379            let unlisted = unlisted_counters.entry(parent_id).or_insert(0);
380
381            for (name, entry) in entries {
382                let position = get_position(name, &position_map, unlisted);
383
384                match entry {
385                    Entry::Bookmark(bm) => {
386                        // Get or create place_id for the URL
387                        let place_id = get_or_create_place_id(&tx, &bm.url)?;
388
389                        // Insert bookmark with position from index
390                        db::insert_bookmark(
391                            &tx,
392                            parent_id,
393                            place_id,
394                            &bm.title,
395                            position,
396                            &bm.guid,
397                            bm.date_added,
398                            bm.last_modified,
399                        )?;
400
401                        // Set keyword if present
402                        if !bm.keyword.is_empty() {
403                            set_keyword(&tx, place_id, &bm.keyword)?;
404                        }
405
406                        // Add tags
407                        for tag in &bm.tags {
408                            db::add_tag(&tx, place_id, tag)?;
409                        }
410
411                        stats.bookmarks_imported += 1;
412                    }
413                    Entry::Separator(sep) => {
414                        db::insert_separator(
415                            &tx,
416                            parent_id,
417                            position,
418                            &sep.guid,
419                            sep.date_added,
420                            sep.last_modified,
421                        )?;
422                        stats.separators_imported += 1;
423                    }
424                }
425            }
426        }
427    }
428
429    tx.commit()?;
430
431    // Vacuum the database to reclaim space
432    conn.execute("VACUUM", [])?;
433
434    Ok(stats)
435}
436
437/// Statistics from an import operation.
438#[derive(Debug, Default)]
439pub struct ImportStats {
440    /// Number of folders created.
441    pub folders_created: usize,
442    /// Number of bookmarks imported.
443    pub bookmarks_imported: usize,
444    /// Number of separators imported.
445    pub separators_imported: usize,
446}