1use std::collections::{HashMap, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::db::{
8 self, build_folder_path, build_folder_titles, build_folder_tree, db_bookmark_to_export,
9 db_separator_to_export,
10};
11use crate::error::Result;
12use crate::types::{Folder, FolderIndex, root_dir_name, roots};
13
14fn sanitize_filename(title: &str) -> String {
18 let sanitized: String = title
19 .chars()
20 .map(|c| match c {
21 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0'..='\x1f' => '_',
22 _ => c,
23 })
24 .collect();
25
26 let trimmed = sanitized.trim();
28 if trimmed.len() > 100 {
29 trimmed
30 .char_indices()
31 .take_while(|(i, _)| *i < 100)
32 .map(|(_, c)| c)
33 .collect()
34 } else if trimmed.is_empty() {
35 "untitled".to_string()
36 } else {
37 trimmed.to_string()
38 }
39}
40
41fn find_root_folder(folder_id: i64, folder_tree: &HashMap<i64, i64>) -> i64 {
43 let mut current = folder_id;
44 while let Some(&parent) = folder_tree.get(¤t) {
45 if parent == roots::ROOT {
46 return current;
47 }
48 current = parent;
49 }
50 folder_id
51}
52
53fn folder_depth(f: &Folder, tree: &HashMap<i64, i64>) -> usize {
55 let mut depth = 0;
56 let mut current = f.parent;
57 while let Some(&parent) = tree.get(¤t) {
58 depth += 1;
59 if current <= roots::MOBILE {
60 break;
61 }
62 current = parent;
63 }
64 depth
65}
66
67struct IndexItem {
69 name: String,
72 position: i32,
74}
75
76#[expect(clippy::too_many_lines)]
84pub fn export_bookmarks(db_path: &Path, export_dir: &Path) -> Result<ExportStats> {
85 let conn = db::open_readonly(db_path)?;
86
87 let folders = db::read_folders(&conn)?;
88 let bookmarks = db::read_bookmarks(&conn)?;
89 let separators = db::read_separators(&conn)?;
90
91 let folder_tree = build_folder_tree(&folders);
92 let folder_titles = build_folder_titles(&folders);
93
94 let folder_map: HashMap<i64, &Folder> = folders.iter().map(|f| (f.id, f)).collect();
96
97 let mut stats = ExportStats::default();
98
99 let mut created_dirs: HashSet<PathBuf> = HashSet::new();
101
102 let pre_existing: HashSet<PathBuf> = if export_dir.exists() {
104 walkdir::WalkDir::new(export_dir)
105 .into_iter()
106 .filter_map(std::result::Result::ok)
107 .filter(|e| e.file_type().is_file())
108 .map(|e| e.path().to_path_buf())
109 .collect()
110 } else {
111 HashSet::new()
112 };
113
114 let mut written_this_session: HashSet<PathBuf> = HashSet::new();
116
117 let mut folder_indices: HashMap<PathBuf, Vec<IndexItem>> = HashMap::new();
119
120 for root_id in [roots::MENU, roots::TOOLBAR, roots::OTHER, roots::MOBILE] {
122 if let Some(dir_name) = root_dir_name(root_id) {
123 let root_path = export_dir.join(dir_name);
124 if !root_path.exists() {
125 fs::create_dir_all(&root_path)?;
126 }
127 created_dirs.insert(root_path);
128 }
129 }
130
131 let mut sorted_folders: Vec<&Folder> = folders
134 .iter()
135 .filter(|f| f.id > roots::MOBILE && f.parent != roots::TAGS)
136 .collect();
137
138 sorted_folders.sort_by_key(|f| folder_depth(f, &folder_tree));
139
140 let folder_positions: HashMap<i64, i32> = {
143 let mut stmt = conn.prepare("SELECT id, position FROM moz_bookmarks WHERE type = 2")?;
144 stmt.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i32>(1)?)))?
145 .filter_map(std::result::Result::ok)
146 .collect()
147 };
148
149 for folder in &sorted_folders {
151 let root = find_root_folder(folder.id, &folder_tree);
152 if let Some(root_name) = root_dir_name(root) {
153 let path = build_folder_path(folder.id, &folder_tree, &folder_titles);
154 let mut dir_path = export_dir.join(root_name);
155 for segment in &path {
156 dir_path = dir_path.join(sanitize_filename(segment));
157 }
158 if !dir_path.exists() {
159 fs::create_dir_all(&dir_path)?;
160 stats.folders_created += 1;
161 }
162
163 if let Some(parent_dir) = dir_path.parent() {
165 let folder_name = sanitize_filename(&folder.title);
166 let position = folder_positions.get(&folder.id).copied().unwrap_or(0);
167 folder_indices
168 .entry(parent_dir.to_path_buf())
169 .or_default()
170 .push(IndexItem {
171 name: format!("{folder_name}/"),
172 position,
173 });
174 }
175
176 created_dirs.insert(dir_path);
177 }
178 }
179
180 for db_bm in &bookmarks {
182 if db_bm.parent == roots::TAGS {
184 continue;
185 }
186 if let Some(parent_folder) = folder_map.get(&db_bm.parent)
187 && parent_folder.parent == roots::TAGS
188 {
189 continue;
190 }
191
192 let root = find_root_folder(db_bm.parent, &folder_tree);
193 let Some(root_name) = root_dir_name(root) else {
194 continue;
195 };
196
197 let mut dir_path = export_dir.join(root_name);
199 if db_bm.parent != root {
200 let path = build_folder_path(db_bm.parent, &folder_tree, &folder_titles);
201 for segment in &path {
202 dir_path = dir_path.join(sanitize_filename(segment));
203 }
204 }
205
206 if !dir_path.exists() {
208 fs::create_dir_all(&dir_path)?;
209 }
210
211 let Some(bookmark) = db_bookmark_to_export(&conn, db_bm)? else {
213 continue;
214 };
215
216 let base_name = sanitize_filename(&bookmark.title);
219 let base_path = dir_path.join(format!("{base_name}.toml"));
220 if pre_existing.contains(&base_path) {
221 stats.bookmarks_skipped += 1;
222 folder_indices
224 .entry(dir_path.clone())
225 .or_default()
226 .push(IndexItem {
227 name: base_name,
228 position: db_bm.position,
229 });
230 continue;
231 }
232
233 let (file_path, index_name) = {
235 let mut path = base_path;
236 let mut name = base_name.clone();
237 let mut counter = 1;
238 while path.exists() || written_this_session.contains(&path) {
239 name = format!("{base_name}-{counter}");
240 path = dir_path.join(format!("{name}.toml"));
241 counter += 1;
242 }
243 (path, name)
244 };
245
246 let toml_content = toml::to_string_pretty(&bookmark)?;
248 fs::write(&file_path, toml_content)?;
249 written_this_session.insert(file_path);
250 stats.bookmarks_exported += 1;
251
252 folder_indices.entry(dir_path).or_default().push(IndexItem {
254 name: index_name,
255 position: db_bm.position,
256 });
257 }
258
259 for db_sep in &separators {
261 let root = find_root_folder(db_sep.parent, &folder_tree);
262 let Some(root_name) = root_dir_name(root) else {
263 continue;
264 };
265
266 let mut dir_path = export_dir.join(root_name);
268 if db_sep.parent != root {
269 let path = build_folder_path(db_sep.parent, &folder_tree, &folder_titles);
270 for segment in &path {
271 dir_path = dir_path.join(sanitize_filename(segment));
272 }
273 }
274
275 if !dir_path.exists() {
277 fs::create_dir_all(&dir_path)?;
278 }
279
280 let separator = db_separator_to_export(db_sep);
281 let base_name = format!("---{}", separator.guid);
283 let base_path = dir_path.join(format!("{base_name}.separator"));
284 if pre_existing.contains(&base_path) {
285 stats.separators_skipped += 1;
286 folder_indices
288 .entry(dir_path.clone())
289 .or_default()
290 .push(IndexItem {
291 name: base_name,
292 position: db_sep.position,
293 });
294 continue;
295 }
296
297 let (file_path, index_name) = {
298 let mut path = base_path;
299 let mut name = base_name.clone();
300 let mut counter = 1;
301 while path.exists() || written_this_session.contains(&path) {
302 name = format!("{base_name}-{counter}");
303 path = dir_path.join(format!("{name}.separator"));
304 counter += 1;
305 }
306 (path, name)
307 };
308
309 let toml_content = toml::to_string_pretty(&separator)?;
310 fs::write(&file_path, toml_content)?;
311 written_this_session.insert(file_path);
312 stats.separators_exported += 1;
313
314 folder_indices.entry(dir_path).or_default().push(IndexItem {
316 name: index_name,
317 position: db_sep.position,
318 });
319 }
320
321 for (dir_path, mut items) in folder_indices {
323 items.sort_by_key(|item| item.position);
325
326 let index = FolderIndex {
327 order: items.into_iter().map(|item| item.name).collect(),
328 };
329
330 let index_path = dir_path.join("__index.toml");
331 let toml_content = toml::to_string_pretty(&index)?;
332 fs::write(index_path, toml_content)?;
333 stats.indexes_written += 1;
334 }
335
336 Ok(stats)
337}
338
339#[derive(Debug, Default)]
341pub struct ExportStats {
342 pub folders_created: usize,
344 pub bookmarks_exported: usize,
346 pub bookmarks_skipped: usize,
348 pub separators_exported: usize,
350 pub separators_skipped: usize,
352 pub indexes_written: usize,
354}