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_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#[derive(Debug)]
37enum Entry {
38 Bookmark(Bookmark),
39 Separator(Separator),
40}
41
42type FolderData = Vec<(String, Vec<(String, Entry)>)>;
44
45fn 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
52fn 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
59fn 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
70fn 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 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
88fn 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
104fn 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 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 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 let index = read_folder_index(dir);
142
143 if let Some(idx) = index {
144 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 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 indexed.sort_by_key(|(pos, _)| *pos);
166
167 unlisted.sort_by(|(a, _), (b, _)| a.cmp(b));
169
170 entries = indexed.into_iter().map(|(_, e)| e).collect();
172 entries.extend(unlisted);
173 } else {
174 file_entries.sort_by(|(a, _), (b, _)| a.cmp(b));
176 entries = file_entries;
177 }
178
179 Ok(entries)
180}
181
182fn 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 let entries = read_directory_entries(dir)?;
195 folders.push((parent_path.to_string(), entries));
198
199 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 let index = read_folder_index(dir);
207
208 if let Some(idx) = index {
209 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 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 indexed.sort_by_key(|(pos, _)| *pos);
232
233 unlisted.sort_by_key(std::fs::DirEntry::file_name);
235
236 subdirs = indexed.into_iter().map(|(_, e)| e).collect();
238 subdirs.extend(unlisted);
239 } else {
240 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#[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 db::delete_all_bookmarks(&tx)?;
274
275 let mut folder_ids: HashMap<String, i64> = HashMap::new();
277
278 let mut position_maps: HashMap<String, HashMap<String, i32>> = HashMap::new();
280
281 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 let folder_data = read_folder_structure(&root_dir, "")?;
294
295 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 let mut unlisted_counters: HashMap<i64, i32> = HashMap::new();
313
314 for (folder_path, _) in &folder_data {
316 if folder_path.is_empty() {
317 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 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 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 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 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 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 let place_id = get_or_create_place_id(&tx, &bm.url)?;
376
377 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 if !bm.keyword.is_empty() {
391 set_keyword(&tx, place_id, &bm.keyword)?;
392 }
393
394 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 conn.execute("VACUUM", [])?;
421
422 Ok(stats)
423}
424
425#[derive(Debug, Default)]
427pub struct ImportStats {
428 pub folders_created: usize,
430 pub bookmarks_imported: usize,
432 pub separators_imported: usize,
434}