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::{root_dir_name, roots, Folder, FolderIndex};
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#[allow(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(
144 "SELECT id, position FROM moz_bookmarks WHERE type = 2",
145 )?;
146 stmt.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i32>(1)?)))?
147 .filter_map(std::result::Result::ok)
148 .collect()
149 };
150
151 for folder in &sorted_folders {
153 let root = find_root_folder(folder.id, &folder_tree);
154 if let Some(root_name) = root_dir_name(root) {
155 let path = build_folder_path(folder.id, &folder_tree, &folder_titles);
156 let mut dir_path = export_dir.join(root_name);
157 for segment in &path {
158 dir_path = dir_path.join(sanitize_filename(segment));
159 }
160 if !dir_path.exists() {
161 fs::create_dir_all(&dir_path)?;
162 stats.folders_created += 1;
163 }
164
165 if let Some(parent_dir) = dir_path.parent() {
167 let folder_name = sanitize_filename(&folder.title);
168 let position = folder_positions.get(&folder.id).copied().unwrap_or(0);
169 folder_indices
170 .entry(parent_dir.to_path_buf())
171 .or_default()
172 .push(IndexItem {
173 name: format!("{folder_name}/"),
174 position,
175 });
176 }
177
178 created_dirs.insert(dir_path);
179 }
180 }
181
182 for db_bm in &bookmarks {
184 if db_bm.parent == roots::TAGS {
186 continue;
187 }
188 if let Some(parent_folder) = folder_map.get(&db_bm.parent)
189 && parent_folder.parent == roots::TAGS
190 {
191 continue;
192 }
193
194 let root = find_root_folder(db_bm.parent, &folder_tree);
195 let Some(root_name) = root_dir_name(root) else {
196 continue;
197 };
198
199 let mut dir_path = export_dir.join(root_name);
201 if db_bm.parent != root {
202 let path = build_folder_path(db_bm.parent, &folder_tree, &folder_titles);
203 for segment in &path {
204 dir_path = dir_path.join(sanitize_filename(segment));
205 }
206 }
207
208 if !dir_path.exists() {
210 fs::create_dir_all(&dir_path)?;
211 }
212
213 let Some(bookmark) = db_bookmark_to_export(&conn, db_bm)? else {
215 continue;
216 };
217
218 let base_name = sanitize_filename(&bookmark.title);
221 let base_path = dir_path.join(format!("{base_name}.toml"));
222 if pre_existing.contains(&base_path) {
223 stats.bookmarks_skipped += 1;
224 folder_indices
226 .entry(dir_path.clone())
227 .or_default()
228 .push(IndexItem {
229 name: base_name,
230 position: db_bm.position,
231 });
232 continue;
233 }
234
235 let (file_path, index_name) = {
237 let mut path = base_path;
238 let mut name = base_name.clone();
239 let mut counter = 1;
240 while path.exists() || written_this_session.contains(&path) {
241 name = format!("{base_name}-{counter}");
242 path = dir_path.join(format!("{name}.toml"));
243 counter += 1;
244 }
245 (path, name)
246 };
247
248 let toml_content = toml::to_string_pretty(&bookmark)?;
250 fs::write(&file_path, toml_content)?;
251 written_this_session.insert(file_path);
252 stats.bookmarks_exported += 1;
253
254 folder_indices
256 .entry(dir_path)
257 .or_default()
258 .push(IndexItem {
259 name: index_name,
260 position: db_bm.position,
261 });
262 }
263
264 for db_sep in &separators {
266 let root = find_root_folder(db_sep.parent, &folder_tree);
267 let Some(root_name) = root_dir_name(root) else {
268 continue;
269 };
270
271 let mut dir_path = export_dir.join(root_name);
273 if db_sep.parent != root {
274 let path = build_folder_path(db_sep.parent, &folder_tree, &folder_titles);
275 for segment in &path {
276 dir_path = dir_path.join(sanitize_filename(segment));
277 }
278 }
279
280 if !dir_path.exists() {
282 fs::create_dir_all(&dir_path)?;
283 }
284
285 let separator = db_separator_to_export(db_sep);
286 let base_name = format!("---{}", separator.guid);
288 let base_path = dir_path.join(format!("{base_name}.separator"));
289 if pre_existing.contains(&base_path) {
290 stats.separators_skipped += 1;
291 folder_indices
293 .entry(dir_path.clone())
294 .or_default()
295 .push(IndexItem {
296 name: base_name,
297 position: db_sep.position,
298 });
299 continue;
300 }
301
302 let (file_path, index_name) = {
303 let mut path = base_path;
304 let mut name = base_name.clone();
305 let mut counter = 1;
306 while path.exists() || written_this_session.contains(&path) {
307 name = format!("{base_name}-{counter}");
308 path = dir_path.join(format!("{name}.separator"));
309 counter += 1;
310 }
311 (path, name)
312 };
313
314 let toml_content = toml::to_string_pretty(&separator)?;
315 fs::write(&file_path, toml_content)?;
316 written_this_session.insert(file_path);
317 stats.separators_exported += 1;
318
319 folder_indices
321 .entry(dir_path)
322 .or_default()
323 .push(IndexItem {
324 name: index_name,
325 position: db_sep.position,
326 });
327 }
328
329 for (dir_path, mut items) in folder_indices {
331 items.sort_by_key(|item| item.position);
333
334 let index = FolderIndex {
335 order: items.into_iter().map(|item| item.name).collect(),
336 };
337
338 let index_path = dir_path.join("__index.toml");
339 let toml_content = toml::to_string_pretty(&index)?;
340 fs::write(index_path, toml_content)?;
341 stats.indexes_written += 1;
342 }
343
344 Ok(stats)
345}
346
347#[derive(Debug, Default)]
349pub struct ExportStats {
350 pub folders_created: usize,
352 pub bookmarks_exported: usize,
354 pub bookmarks_skipped: usize,
356 pub separators_exported: usize,
358 pub separators_skipped: usize,
360 pub indexes_written: usize,
362}