1use 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
11const UNLISTED_POSITION_BASE: i32 = 100_000;
14
15const GUID_CHARS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-";
17
18fn 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#[derive(Debug)]
38enum Entry {
39 Bookmark(Bookmark),
40 Separator(Separator),
41}
42
43type FolderData = Vec<(String, Vec<(String, Entry)>)>;
45
46fn 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
53fn 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
60fn 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
71fn 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 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
89fn 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
105fn 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 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 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 let index = read_folder_index(dir);
143
144 if let Some(idx) = index {
145 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 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 indexed.sort_by_key(|(pos, _)| *pos);
167
168 unlisted.sort_by(|(a, _), (b, _)| a.cmp(b));
170
171 entries = indexed.into_iter().map(|(_, e)| e).collect();
173 entries.extend(unlisted);
174 } else {
175 file_entries.sort_by(|(a, _), (b, _)| a.cmp(b));
177 entries = file_entries;
178 }
179
180 Ok(entries)
181}
182
183fn 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 let entries = read_directory_entries(dir)?;
196 folders.push((parent_path.to_string(), entries));
199
200 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 let index = read_folder_index(dir);
208
209 if let Some(idx) = index {
210 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 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 indexed.sort_by_key(|(pos, _)| *pos);
233
234 unlisted.sort_by_key(std::fs::DirEntry::file_name);
236
237 subdirs = indexed.into_iter().map(|(_, e)| e).collect();
239 subdirs.extend(unlisted);
240 } else {
241 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#[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 db::delete_all_bookmarks(&tx)?;
275
276 let mut folder_ids: HashMap<String, i64> = HashMap::new();
278
279 let mut position_maps: HashMap<String, HashMap<String, i32>> = HashMap::new();
281
282 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 let folder_data = read_folder_structure(&root_dir, "")?;
295
296 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 let mut unlisted_counters: HashMap<i64, i32> = HashMap::new();
314
315 for (folder_path, _) in &folder_data {
317 if folder_path.is_empty() {
318 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 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 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 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 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 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 let place_id = get_or_create_place_id(&tx, &bm.url)?;
378
379 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 if !bm.keyword.is_empty() {
393 set_keyword(&tx, place_id, &bm.keyword)?;
394 }
395
396 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 conn.execute("VACUUM", [])?;
423
424 Ok(stats)
425}
426
427#[derive(Debug, Default)]
429pub struct ImportStats {
430 pub folders_created: usize,
432 pub bookmarks_imported: usize,
434 pub separators_imported: usize,
436}