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::{dir_name_to_root, Bookmark, FolderIndex, Separator};
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 #[allow(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)| {
216 name.strip_suffix('/').map(|n| (n, i))
217 })
218 .collect();
219
220 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 indexed.sort_by_key(|(pos, _)| *pos);
235
236 unlisted.sort_by_key(std::fs::DirEntry::file_name);
238
239 subdirs = indexed.into_iter().map(|(_, e)| e).collect();
241 subdirs.extend(unlisted);
242 } else {
243 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#[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 db::delete_all_bookmarks(&tx)?;
277
278 let mut folder_ids: HashMap<String, i64> = HashMap::new();
280
281 let mut position_maps: HashMap<String, HashMap<String, i32>> = HashMap::new();
283
284 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 let folder_data = read_folder_structure(&root_dir, "")?;
297
298 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 let mut unlisted_counters: HashMap<i64, i32> = HashMap::new();
316
317 for (folder_path, _) in &folder_data {
319 if folder_path.is_empty() {
320 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 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 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 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 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 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 let place_id = get_or_create_place_id(&tx, &bm.url)?;
388
389 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 if !bm.keyword.is_empty() {
403 set_keyword(&tx, place_id, &bm.keyword)?;
404 }
405
406 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 conn.execute("VACUUM", [])?;
433
434 Ok(stats)
435}
436
437#[derive(Debug, Default)]
439pub struct ImportStats {
440 pub folders_created: usize,
442 pub bookmarks_imported: usize,
444 pub separators_imported: usize,
446}