1use crate::bookmarks_html::{ImportedBookmark, export_netscape_html, parse_netscape_html};
10use crate::host::{HostCancel, HostResult, StreamContext, await_or_cancel};
11use crate::platform_error::map_platform_error;
12use crate::url_match::normalize_url;
13use lingxia_platform::traits::app_runtime::AppRuntime;
14use lingxia_service::file::{ChooseFileRequest, FileDialogFilter};
15use lxapp::LxApp;
16use lxapp::LxAppError;
17use serde::{Deserialize, Serialize};
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use std::sync::atomic::{AtomicU64, Ordering};
21use std::sync::{Mutex, OnceLock};
22use std::time::{SystemTime, UNIX_EPOCH};
23use tokio::sync::broadcast;
24
25const BOOKMARKS_FILE: &str = "browser-bookmarks.json";
26const CURRENT_VERSION: u32 = 1;
27const MAX_IMPORT_FILE_BYTES: u64 = 16 * 1024 * 1024;
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct BookmarkGroup {
32 pub id: String,
33 pub name: String,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct BookmarkEntry {
39 pub id: String,
40 pub url: String,
41 #[serde(default)]
42 pub title: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub group_id: Option<String>,
45 #[serde(default)]
46 pub created_at_ms: u64,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
52#[serde(rename_all = "camelCase")]
53pub struct BookmarksSnapshot {
54 #[serde(default)]
55 version: u32,
56 #[serde(default)]
57 pub groups: Vec<BookmarkGroup>,
58 #[serde(default)]
59 pub entries: Vec<BookmarkEntry>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "camelCase")]
64struct BookmarksView {
65 version: u32,
66 groups: Vec<BookmarkGroup>,
67 entries: Vec<BookmarkEntry>,
68 pinned_ids: Vec<String>,
69}
70
71fn bookmarks_view(snapshot: BookmarksSnapshot) -> BookmarksView {
72 bookmarks_view_with_pins(snapshot, lingxia_shell::pins().unwrap_or_default())
73}
74
75fn bookmarks_view_with_pins(
76 snapshot: BookmarksSnapshot,
77 pins: Vec<lingxia_shell::ShellPin>,
78) -> BookmarksView {
79 let pinned_ids = pins
80 .into_iter()
81 .filter_map(|pin| match pin.0 {
82 lingxia_shell::ShellPinTarget::Bookmark { key } => Some(key),
83 lingxia_shell::ShellPinTarget::Lxapp { .. } => None,
84 })
85 .collect();
86 BookmarksView {
87 version: snapshot.version,
88 groups: snapshot.groups,
89 entries: snapshot.entries,
90 pinned_ids,
91 }
92}
93
94fn now_ms() -> u64 {
95 SystemTime::now()
96 .duration_since(UNIX_EPOCH)
97 .map(|d| d.as_millis() as u64)
98 .unwrap_or(0)
99}
100
101fn next_id(prefix: &str) -> String {
102 static SEQ: AtomicU64 = AtomicU64::new(0);
103 format!(
104 "{prefix}{}-{}",
105 now_ms(),
106 SEQ.fetch_add(1, Ordering::Relaxed)
107 )
108}
109
110fn default_title(url: &str) -> String {
111 url.split_once("://")
112 .map(|(_, rest)| rest.split(['/', '?']).next().unwrap_or(rest))
113 .unwrap_or(url)
114 .to_string()
115}
116
117fn bookmarks_path(app_data_dir: &Path) -> PathBuf {
120 lingxia_app_context::app_state_file(app_data_dir, BOOKMARKS_FILE)
121}
122
123fn store_lock() -> &'static Mutex<()> {
124 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
125 LOCK.get_or_init(|| Mutex::new(()))
126}
127
128fn channel() -> &'static broadcast::Sender<BookmarksSnapshot> {
129 static CHANNEL: OnceLock<broadcast::Sender<BookmarksSnapshot>> = OnceLock::new();
130 CHANNEL.get_or_init(|| broadcast::channel(32).0)
131}
132
133fn load(app_data_dir: &Path) -> Result<BookmarksSnapshot, LxAppError> {
134 let path = bookmarks_path(app_data_dir);
135 if !path.is_file() {
136 return Ok(BookmarksSnapshot {
137 version: CURRENT_VERSION,
138 ..Default::default()
139 });
140 }
141 let content = std::fs::read_to_string(&path)
142 .map_err(|e| LxAppError::IoError(format!("read {}: {e}", path.display())))?;
143 match serde_json::from_str(&content) {
144 Ok(snapshot) => Ok(snapshot),
145 Err(e) => {
146 log::error!(
149 "[BrowserBookmarks] corrupt {}: {e}; starting fresh",
150 path.display()
151 );
152 let _ = std::fs::rename(&path, path.with_extension("json.corrupt"));
153 Ok(BookmarksSnapshot {
154 version: CURRENT_VERSION,
155 ..Default::default()
156 })
157 }
158 }
159}
160
161fn save(app_data_dir: &Path, snapshot: &BookmarksSnapshot) -> Result<(), LxAppError> {
162 let path = bookmarks_path(app_data_dir);
163 if let Some(parent) = path.parent() {
164 std::fs::create_dir_all(parent)
165 .map_err(|e| LxAppError::IoError(format!("mkdir {}: {e}", parent.display())))?;
166 }
167 let json = serde_json::to_string_pretty(snapshot)
168 .map_err(|e| LxAppError::Bridge(format!("JSON Processing Error: {e}")))?;
169 let tmp = path.with_extension("json.tmp");
170 std::fs::write(&tmp, json)
171 .map_err(|e| LxAppError::IoError(format!("write {}: {e}", tmp.display())))?;
172 replace_saved_file(&tmp, &path)?;
173 Ok(())
174}
175
176#[cfg(not(windows))]
177fn replace_saved_file(tmp: &Path, path: &Path) -> Result<(), LxAppError> {
178 std::fs::rename(tmp, path)
179 .map_err(|e| LxAppError::IoError(format!("rename {}: {e}", path.display())))
180}
181
182#[cfg(windows)]
183fn replace_saved_file(tmp: &Path, path: &Path) -> Result<(), LxAppError> {
184 let backup = path.with_extension("json.bak");
185 if backup.exists() {
186 std::fs::remove_file(&backup)
187 .map_err(|e| LxAppError::IoError(format!("remove {}: {e}", backup.display())))?;
188 }
189 let had_previous = path.exists();
190 if had_previous {
191 std::fs::rename(path, &backup)
192 .map_err(|e| LxAppError::IoError(format!("backup {}: {e}", path.display())))?;
193 }
194 if let Err(error) = std::fs::rename(tmp, path) {
195 if had_previous {
196 let _ = std::fs::rename(&backup, path);
197 }
198 return Err(LxAppError::IoError(format!(
199 "replace {}: {error}",
200 path.display()
201 )));
202 }
203 if had_previous {
204 let _ = std::fs::remove_file(backup);
205 }
206 Ok(())
207}
208
209fn change_listener() -> &'static OnceLock<Box<dyn Fn() + Send + Sync>> {
211 static LISTENER: OnceLock<Box<dyn Fn() + Send + Sync>> = OnceLock::new();
212 &LISTENER
213}
214
215pub fn set_change_listener(listener: Box<dyn Fn() + Send + Sync>) {
217 let _ = change_listener().set(listener);
218}
219
220fn mutate<T>(
222 app_data_dir: &Path,
223 op: impl FnOnce(&mut BookmarksSnapshot) -> Result<T, LxAppError>,
224) -> Result<T, LxAppError> {
225 let out = {
226 let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
227 let mut snapshot = load(app_data_dir)?;
228 let out = op(&mut snapshot)?;
229 snapshot.version = CURRENT_VERSION;
230 save(app_data_dir, &snapshot)?;
231 let _ = channel().send(snapshot);
232 out
233 };
234 if let Some(listener) = change_listener().get() {
235 listener();
236 }
237 Ok(out)
238}
239
240fn find_entry<'a>(
241 snapshot: &'a BookmarksSnapshot,
242 normalized_url: &str,
243) -> Option<&'a BookmarkEntry> {
244 snapshot
245 .entries
246 .iter()
247 .find(|e| normalize_url(&e.url) == normalized_url)
248}
249
250fn add_entry(
251 snapshot: &mut BookmarksSnapshot,
252 url: &str,
253 title: Option<&str>,
254 group_id: Option<&str>,
255) -> Result<(BookmarkEntry, bool), LxAppError> {
256 let url = url.trim();
257 if url.is_empty() {
258 return Err(LxAppError::InvalidParameter(
259 "bookmark url must not be empty".to_string(),
260 ));
261 }
262 if !crate::address_bar::is_valid_explicit_http_url(url) {
263 return Err(LxAppError::InvalidParameter(
264 "bookmark url must be an http or https URL with a host".to_string(),
265 ));
266 }
267 let normalized = normalize_url(url);
268 if let Some(existing) = find_entry(snapshot, &normalized) {
269 return Ok((existing.clone(), false));
270 }
271 if let Some(gid) = group_id
272 && !snapshot.groups.iter().any(|g| g.id == gid)
273 {
274 return Err(LxAppError::ResourceNotFound(format!(
275 "bookmark group not found: {gid}"
276 )));
277 }
278 let title = title
279 .map(str::trim)
280 .filter(|t| !t.is_empty())
281 .map(str::to_string)
282 .unwrap_or_else(|| default_title(url));
283 let entry = BookmarkEntry {
284 id: next_id("b"),
285 url: url.to_string(),
286 title,
287 group_id: group_id.map(str::to_string),
288 created_at_ms: now_ms(),
289 };
290 snapshot.entries.push(entry.clone());
291 Ok((entry, true))
292}
293
294fn rename_entry_op(
299 snapshot: &mut BookmarksSnapshot,
300 id: &str,
301 title: &str,
302) -> Result<BookmarkEntry, LxAppError> {
303 let title = validated_name(title, "bookmark title")?;
304 let entry = snapshot
305 .entries
306 .iter_mut()
307 .find(|e| e.id == id)
308 .ok_or_else(|| LxAppError::ResourceNotFound(format!("bookmark not found: {id}")))?;
309 entry.title = title;
310 Ok(entry.clone())
311}
312
313fn move_entry_op(
314 snapshot: &mut BookmarksSnapshot,
315 id: &str,
316 group_id: Option<String>,
317) -> Result<BookmarkEntry, LxAppError> {
318 if let Some(gid) = group_id.as_deref()
319 && !snapshot.groups.iter().any(|g| g.id == gid)
320 {
321 return Err(LxAppError::ResourceNotFound(format!(
322 "bookmark group not found: {gid}"
323 )));
324 }
325 let entry = snapshot
326 .entries
327 .iter_mut()
328 .find(|e| e.id == id)
329 .ok_or_else(|| LxAppError::ResourceNotFound(format!("bookmark not found: {id}")))?;
330 entry.group_id = group_id;
331 Ok(entry.clone())
332}
333
334fn rename_group_op(
335 snapshot: &mut BookmarksSnapshot,
336 id: &str,
337 name: &str,
338) -> Result<BookmarkGroup, LxAppError> {
339 let name = validated_name(name, "group name")?;
340 if snapshot
341 .groups
342 .iter()
343 .any(|group| group.name == name && group.id != id)
344 {
345 return Err(LxAppError::InvalidParameter(format!(
346 "group already exists: {name}"
347 )));
348 }
349 let group = snapshot
350 .groups
351 .iter_mut()
352 .find(|g| g.id == id)
353 .ok_or_else(|| LxAppError::ResourceNotFound(format!("bookmark group not found: {id}")))?;
354 group.name = name;
355 Ok(group.clone())
356}
357
358fn delete_group_op(snapshot: &mut BookmarksSnapshot, id: &str) -> Result<(), LxAppError> {
359 let before = snapshot.groups.len();
360 snapshot.groups.retain(|g| g.id != id);
361 if snapshot.groups.len() == before {
362 return Err(LxAppError::ResourceNotFound(format!(
363 "bookmark group not found: {id}"
364 )));
365 }
366 for entry in snapshot
368 .entries
369 .iter_mut()
370 .filter(|e| e.group_id.as_deref() == Some(id))
371 {
372 entry.group_id = None;
373 }
374 Ok(())
375}
376
377fn runtime_data_dir() -> Option<PathBuf> {
380 lxapp::get_platform().map(|runtime| runtime.app_data_dir())
381}
382
383pub fn is_bookmarked(url: &str) -> bool {
386 let Some(dir) = runtime_data_dir() else {
387 return false;
388 };
389 let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
390 match load(&dir) {
391 Ok(snapshot) => find_entry(&snapshot, &normalize_url(url)).is_some(),
392 Err(_) => false,
393 }
394}
395
396pub fn snapshot_json() -> Option<String> {
398 let dir = runtime_data_dir()?;
399 let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
400 let snapshot = load(&dir).ok()?;
401 serde_json::to_string(&snapshot).ok()
402}
403
404pub fn normalize_url_for_match(raw: &str) -> String {
407 normalize_url(raw)
408}
409
410pub fn snapshot() -> Option<BookmarksSnapshot> {
413 let dir = runtime_data_dir()?;
414 let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
415 load(&dir).ok()
416}
417
418pub fn favicon_path(url: &str) -> Option<String> {
421 let runtime = lxapp::get_platform()?;
422 let notify = Arc::new(|| {
423 if let Some(listener) = change_listener().get() {
424 listener();
425 }
426 });
427 lingxia_service::favicon::cached_or_request(&runtime.app_cache_dir(), url, notify)
428 .map(|path| path.to_string_lossy().into_owned())
429}
430
431pub fn store_favicon(url: &str, bytes: &[u8]) -> bool {
436 let Some(runtime) = lxapp::get_platform() else {
437 return false;
438 };
439 let stored =
440 lingxia_service::favicon::store_for_url(&runtime.app_cache_dir(), url, bytes).is_some();
441 if stored && let Some(listener) = change_listener().get() {
442 listener();
443 }
444 stored
445}
446
447fn set_bookmark_pinned(id: &str, pinned: bool) -> Result<(), LxAppError> {
451 lingxia_shell::set_pinned(
452 lingxia_shell::ShellPinTarget::Bookmark {
453 key: id.to_string(),
454 },
455 pinned,
456 )
457 .map_err(|error| LxAppError::InvalidParameter(error.to_string()))?;
458 if let Some(dir) = runtime_data_dir() {
459 let snapshot = {
460 let _guard = store_lock()
461 .lock()
462 .unwrap_or_else(|error| error.into_inner());
463 load(&dir).ok()
464 };
465 if let Some(snapshot) = snapshot {
466 let _ = channel().send(snapshot);
467 }
468 }
469 Ok(())
470}
471
472pub fn pin_url(url: &str, title: &str) -> bool {
473 pin_url_with_favicon(url, title, None)
474}
475
476pub fn pin_url_with_favicon(url: &str, title: &str, favicon_png: Option<&[u8]>) -> bool {
478 let Some(runtime) = lxapp::get_platform() else {
479 return false;
480 };
481 let dir = runtime.app_data_dir();
482 let title = if title.trim().is_empty() {
483 None
484 } else {
485 Some(title)
486 };
487 let entry = match mutate(&dir, |snapshot| {
488 add_entry(snapshot, url, title, None).map(|(entry, _)| entry)
489 }) {
490 Ok(entry) => entry,
491 Err(error) => {
492 log::warn!("[BrowserBookmarks] bookmark before Pin failed: {error}");
493 return false;
494 }
495 };
496 if let Err(error) = set_bookmark_pinned(&entry.id, true) {
497 log::warn!("[BrowserBookmarks] pin failed: {error}");
498 return false;
499 }
500 if let Some(bytes) = favicon_png
501 && lingxia_service::favicon::store_for_url(&runtime.app_cache_dir(), url, bytes).is_some()
502 && let Some(listener) = change_listener().get()
503 {
504 listener();
505 }
506 true
507}
508
509pub fn remove_by_url(url: &str) -> bool {
511 let Some(dir) = runtime_data_dir() else {
512 return false;
513 };
514 let removed = mutate(&dir, |snapshot| {
515 let normalized = normalize_url(url);
516 let id = snapshot
517 .entries
518 .iter()
519 .find(|entry| normalize_url(&entry.url) == normalized)
520 .map(|entry| entry.id.clone());
521 let Some(id) = id else { return Ok(None) };
522 snapshot.entries.retain(|entry| entry.id != id);
523 Ok(Some(id))
524 })
525 .ok()
526 .flatten();
527 if let Some(id) = removed {
528 let _ = set_bookmark_pinned(&id, false);
529 true
530 } else {
531 false
532 }
533}
534
535pub fn command_json(json: &str) -> bool {
539 #[derive(Deserialize)]
540 #[serde(tag = "op", rename_all = "camelCase")]
541 enum Command {
542 #[serde(rename_all = "camelCase")]
543 Rename { id: String, title: String },
544 #[serde(rename_all = "camelCase")]
545 Move {
546 id: String,
547 #[serde(default)]
548 group_id: Option<String>,
549 },
550 #[serde(rename_all = "camelCase")]
551 CreateGroupAndMove { id: String, name: String },
552 #[serde(rename_all = "camelCase")]
553 RenameGroup { id: String, name: String },
554 #[serde(rename_all = "camelCase")]
555 DeleteGroup { id: String },
556 #[serde(rename_all = "camelCase")]
557 SetPinned { id: String, pinned: bool },
558 }
559
560 let Some(dir) = runtime_data_dir() else {
561 return false;
562 };
563 let command: Command = match serde_json::from_str(json) {
564 Ok(command) => command,
565 Err(err) => {
566 log::warn!("[BrowserBookmarks] bad command: {err}");
567 return false;
568 }
569 };
570 if let Command::SetPinned { id, pinned } = &command {
571 let exists = {
572 let _guard = store_lock()
573 .lock()
574 .unwrap_or_else(|error| error.into_inner());
575 load(&dir)
576 .ok()
577 .is_some_and(|snapshot| snapshot.entries.iter().any(|entry| entry.id == *id))
578 };
579 return exists && set_bookmark_pinned(id, *pinned).is_ok();
580 }
581 let result = mutate(&dir, |snapshot| match command {
582 Command::Rename { id, title } => rename_entry_op(snapshot, &id, &title).map(|_| ()),
583 Command::Move { id, group_id } => move_entry_op(snapshot, &id, group_id).map(|_| ()),
584 Command::CreateGroupAndMove { id, name } => {
585 let name = validated_name(&name, "group name")?;
586 let group_id = match snapshot.groups.iter().find(|g| g.name == name) {
587 Some(existing) => existing.id.clone(),
588 None => {
589 let group = BookmarkGroup {
590 id: next_id("g"),
591 name,
592 };
593 let group_id = group.id.clone();
594 snapshot.groups.push(group);
595 group_id
596 }
597 };
598 move_entry_op(snapshot, &id, Some(group_id)).map(|_| ())
599 }
600 Command::RenameGroup { id, name } => rename_group_op(snapshot, &id, &name).map(|_| ()),
601 Command::SetPinned { .. } => unreachable!(),
602 Command::DeleteGroup { id } => delete_group_op(snapshot, &id),
603 });
604 result
605 .map_err(|e| log::warn!("[BrowserBookmarks] command failed: {e}"))
606 .is_ok()
607}
608
609pub fn toggle_bookmark(url: &str, title: &str) -> Option<bool> {
612 let dir = runtime_data_dir()?;
613 let title = if title.trim().is_empty() {
614 None
615 } else {
616 Some(title)
617 };
618 let (bookmarked, removed_id) = mutate(&dir, |snapshot| {
619 let normalized = normalize_url(url);
620 if let Some(existing) = find_entry(snapshot, &normalized) {
621 let id = existing.id.clone();
622 snapshot.entries.retain(|e| e.id != id);
623 Ok((false, Some(id)))
624 } else {
625 add_entry(snapshot, url, title, None)?;
626 Ok((true, None))
627 }
628 })
629 .map_err(|e| log::warn!("[BrowserBookmarks] toggle failed: {e}"))
630 .ok()?;
631 if let Some(id) = removed_id {
632 let _ = set_bookmark_pinned(&id, false);
633 }
634 Some(bookmarked)
635}
636
637#[derive(Debug, Deserialize)]
640#[serde(rename_all = "camelCase")]
641struct UrlInput {
642 url: String,
643 #[serde(default)]
644 title: Option<String>,
645 #[serde(default)]
646 group_id: Option<String>,
647}
648
649#[derive(Debug, Deserialize)]
650#[serde(rename_all = "camelCase")]
651struct IdInput {
652 id: String,
653}
654
655#[derive(Debug, Deserialize)]
656#[serde(rename_all = "camelCase")]
657struct RenameInput {
658 id: String,
659 title: String,
660}
661
662#[derive(Debug, Deserialize)]
663#[serde(rename_all = "camelCase")]
664struct MoveInput {
665 id: String,
666 #[serde(default)]
667 group_id: Option<String>,
668}
669
670#[derive(Debug, Deserialize)]
671#[serde(rename_all = "camelCase")]
672struct ReorderInput {
673 #[serde(default)]
674 group_id: Option<String>,
675 ordered_ids: Vec<String>,
676}
677
678#[derive(Debug, Deserialize)]
679#[serde(rename_all = "camelCase")]
680struct GroupNameInput {
681 name: String,
682}
683
684#[derive(Debug, Deserialize)]
685#[serde(rename_all = "camelCase")]
686struct GroupRenameInput {
687 id: String,
688 name: String,
689}
690
691#[derive(Debug, Deserialize)]
692#[serde(rename_all = "camelCase")]
693struct OrderedIdsInput {
694 ordered_ids: Vec<String>,
695}
696
697#[derive(Debug, Deserialize)]
698#[serde(rename_all = "camelCase")]
699struct SetPinnedInput {
700 id: String,
701 pinned: bool,
702}
703
704#[derive(Debug, Serialize)]
705struct PinStatus {
706 pinned: bool,
707}
708
709#[derive(Debug, Serialize)]
710#[serde(rename_all = "camelCase")]
711struct AddResult {
712 entry: BookmarkEntry,
713 created: bool,
714}
715
716#[derive(Debug, Serialize)]
717#[serde(rename_all = "camelCase")]
718struct StatusResult {
719 bookmarked: bool,
720 #[serde(skip_serializing_if = "Option::is_none")]
721 entry: Option<BookmarkEntry>,
722}
723
724#[derive(Debug, Serialize, PartialEq, Eq)]
725#[serde(rename_all = "camelCase")]
726struct ImportResult {
727 imported: usize,
728 skipped: usize,
729 groups_created: usize,
730}
731
732#[derive(Debug, Serialize)]
733#[serde(rename_all = "camelCase")]
734struct ExportResult {
735 path: String,
736 file_name: String,
737 count: usize,
738}
739
740fn display_locale_is_chinese() -> bool {
745 is_chinese_locale(&lxapp::get_display_language())
746}
747
748fn is_chinese_locale(language: &str) -> bool {
749 let bytes = language.trim().as_bytes();
751 bytes.len() >= 2
752 && bytes[..2].eq_ignore_ascii_case(b"zh")
753 && (bytes.len() == 2 || matches!(bytes[2], b'-' | b'_'))
754}
755
756fn merge_imported(
757 snapshot: &mut BookmarksSnapshot,
758 bookmarks: Vec<ImportedBookmark>,
759) -> ImportResult {
760 let mut result = ImportResult {
761 imported: 0,
762 skipped: 0,
763 groups_created: 0,
764 };
765 for imported in bookmarks {
766 let url = imported.url.trim();
767 if !crate::address_bar::is_valid_explicit_http_url(url)
768 || find_entry(snapshot, &normalize_url(url)).is_some()
769 {
770 result.skipped += 1;
771 continue;
772 }
773
774 let group_id = imported.group_name.as_deref().map(|name| {
775 if let Some(group) = snapshot.groups.iter().find(|group| group.name == name) {
776 return group.id.clone();
777 }
778 let group = BookmarkGroup {
779 id: next_id("g"),
780 name: name.to_string(),
781 };
782 let id = group.id.clone();
783 snapshot.groups.push(group);
784 result.groups_created += 1;
785 id
786 });
787
788 match add_entry(
789 snapshot,
790 url,
791 (!imported.title.is_empty()).then_some(imported.title.as_str()),
792 group_id.as_deref(),
793 ) {
794 Ok((entry, true)) => {
795 if let Some(created_at_ms) = imported.created_at_ms
796 && let Some(saved) = snapshot
797 .entries
798 .iter_mut()
799 .find(|saved| saved.id == entry.id)
800 {
801 saved.created_at_ms = created_at_ms;
802 }
803 result.imported += 1;
804 }
805 _ => result.skipped += 1,
806 }
807 }
808 result
809}
810
811fn unique_export_path(directory: &Path) -> PathBuf {
812 let base = directory.join("bookmarks.html");
813 if !base.exists() {
814 return base;
815 }
816 for suffix in 1..10_000 {
817 let candidate = directory.join(format!("bookmarks ({suffix}).html"));
818 if !candidate.exists() {
819 return candidate;
820 }
821 }
822 directory.join(format!("bookmarks-{}.html", now_ms()))
823}
824
825fn validated_name(name: &str, what: &str) -> Result<String, LxAppError> {
826 let name = name.trim();
827 if name.is_empty() {
828 return Err(LxAppError::InvalidParameter(format!(
829 "{what} must not be empty"
830 )));
831 }
832 Ok(name.to_string())
833}
834
835fn is_id_permutation(expected: &[&str], ordered: &[String]) -> bool {
836 if expected.len() != ordered.len() {
837 return false;
838 }
839 let mut expected: Vec<&str> = expected.to_vec();
841 let mut ordered: Vec<&str> = ordered.iter().map(String::as_str).collect();
842 expected.sort_unstable();
843 ordered.sort_unstable();
844 expected == ordered
845}
846
847fn reorder_entries(
848 snapshot: &mut BookmarksSnapshot,
849 group_id: Option<&str>,
850 ordered_ids: &[String],
851) -> Result<(), LxAppError> {
852 let in_scope = |e: &BookmarkEntry| e.group_id.as_deref() == group_id;
855 let scope_ids: Vec<&str> = snapshot
856 .entries
857 .iter()
858 .filter(|e| in_scope(e))
859 .map(|e| e.id.as_str())
860 .collect();
861 if !is_id_permutation(&scope_ids, ordered_ids) {
862 return Err(LxAppError::InvalidParameter(
863 "orderedIds must be a permutation of the group's bookmarks".to_string(),
864 ));
865 }
866 let mut by_id: std::collections::HashMap<String, Vec<BookmarkEntry>> =
868 std::collections::HashMap::new();
869 for entry in snapshot.entries.iter().filter(|e| in_scope(e)) {
870 by_id
871 .entry(entry.id.clone())
872 .or_default()
873 .push(entry.clone());
874 }
875 let mut ordered = ordered_ids.iter();
876 for slot in snapshot.entries.iter_mut().filter(|e| in_scope(e)) {
877 let id = ordered.next().expect("scope sizes verified equal");
879 *slot = by_id
880 .get_mut(id)
881 .and_then(Vec::pop)
882 .expect("multiset equality verified");
883 }
884 Ok(())
885}
886
887#[lingxia::native("bookmarks.list")]
888fn list_bookmarks(app: Arc<LxApp>) -> HostResult<BookmarksView> {
889 crate::require_builtin_browser(&app)?;
890 let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
891 load(&app.app_data_dir()).map(bookmarks_view)
892}
893
894#[lingxia::native("bookmarks.add")]
895fn add_bookmark(app: Arc<LxApp>, input: UrlInput) -> HostResult<AddResult> {
896 crate::require_builtin_browser(&app)?;
897 mutate(&app.app_data_dir(), |snapshot| {
898 let (entry, created) = add_entry(
899 snapshot,
900 &input.url,
901 input.title.as_deref(),
902 input.group_id.as_deref(),
903 )?;
904 Ok(AddResult { entry, created })
905 })
906}
907
908#[lingxia::native("bookmarks.remove")]
909fn remove_bookmark(app: Arc<LxApp>, input: IdInput) -> HostResult<()> {
910 crate::require_builtin_browser(&app)?;
911 mutate(&app.app_data_dir(), |snapshot| {
912 let before = snapshot.entries.len();
913 snapshot.entries.retain(|e| e.id != input.id);
914 if snapshot.entries.len() == before {
915 return Err(LxAppError::ResourceNotFound(format!(
916 "bookmark not found: {}",
917 input.id
918 )));
919 }
920 Ok(())
921 })?;
922 set_bookmark_pinned(&input.id, false)
923}
924
925#[lingxia::native("bookmarks.toggle")]
926fn toggle_bookmark_route(app: Arc<LxApp>, input: UrlInput) -> HostResult<StatusResult> {
927 crate::require_builtin_browser(&app)?;
928 let (status, removed_id) = mutate(&app.app_data_dir(), |snapshot| {
929 let normalized = normalize_url(&input.url);
930 if let Some(existing) = find_entry(snapshot, &normalized) {
931 let id = existing.id.clone();
932 snapshot.entries.retain(|e| e.id != id);
933 Ok((
934 StatusResult {
935 bookmarked: false,
936 entry: None,
937 },
938 Some(id),
939 ))
940 } else {
941 let (entry, _) = add_entry(
942 snapshot,
943 &input.url,
944 input.title.as_deref(),
945 input.group_id.as_deref(),
946 )?;
947 Ok((
948 StatusResult {
949 bookmarked: true,
950 entry: Some(entry),
951 },
952 None,
953 ))
954 }
955 })?;
956 if let Some(id) = removed_id {
957 set_bookmark_pinned(&id, false)?;
958 }
959 Ok(status)
960}
961
962#[lingxia::native("bookmarks.getStatus")]
963fn bookmark_status(app: Arc<LxApp>, input: UrlInput) -> HostResult<StatusResult> {
964 crate::require_builtin_browser(&app)?;
965 let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
966 let snapshot = load(&app.app_data_dir())?;
967 let entry = find_entry(&snapshot, &normalize_url(&input.url)).cloned();
968 Ok(StatusResult {
969 bookmarked: entry.is_some(),
970 entry,
971 })
972}
973
974#[lingxia::native("bookmarks.rename")]
975fn rename_bookmark(app: Arc<LxApp>, input: RenameInput) -> HostResult<BookmarkEntry> {
976 crate::require_builtin_browser(&app)?;
977 mutate(&app.app_data_dir(), |snapshot| {
978 rename_entry_op(snapshot, &input.id, &input.title)
979 })
980}
981
982#[lingxia::native("bookmarks.move")]
983fn move_bookmark(app: Arc<LxApp>, input: MoveInput) -> HostResult<BookmarkEntry> {
984 crate::require_builtin_browser(&app)?;
985 mutate(&app.app_data_dir(), |snapshot| {
986 move_entry_op(snapshot, &input.id, input.group_id.clone())
987 })
988}
989
990#[lingxia::native("bookmarks.reorder")]
991fn reorder_bookmarks(app: Arc<LxApp>, input: ReorderInput) -> HostResult<()> {
992 crate::require_builtin_browser(&app)?;
993 mutate(&app.app_data_dir(), |snapshot| {
994 reorder_entries(snapshot, input.group_id.as_deref(), &input.ordered_ids)
995 })
996}
997
998#[lingxia::native("bookmarks.setPinned")]
999fn set_pinned(app: Arc<LxApp>, input: SetPinnedInput) -> HostResult<PinStatus> {
1000 crate::require_builtin_browser(&app)?;
1001 let exists = {
1002 let _guard = store_lock()
1003 .lock()
1004 .unwrap_or_else(|error| error.into_inner());
1005 load(&app.app_data_dir())?
1006 .entries
1007 .into_iter()
1008 .any(|entry| entry.id == input.id)
1009 };
1010 if !exists {
1011 return Err(LxAppError::ResourceNotFound(format!(
1012 "bookmark not found: {}",
1013 input.id
1014 )));
1015 }
1016 set_bookmark_pinned(&input.id, input.pinned)?;
1017 Ok(PinStatus {
1018 pinned: input.pinned,
1019 })
1020}
1021
1022#[lingxia::native("bookmarks.createGroup")]
1023fn create_group(app: Arc<LxApp>, input: GroupNameInput) -> HostResult<BookmarkGroup> {
1024 crate::require_builtin_browser(&app)?;
1025 let name = validated_name(&input.name, "group name")?;
1026 mutate(&app.app_data_dir(), |snapshot| {
1027 if snapshot.groups.iter().any(|g| g.name == name) {
1028 return Err(LxAppError::InvalidParameter(format!(
1029 "group already exists: {name}"
1030 )));
1031 }
1032 let group = BookmarkGroup {
1033 id: next_id("g"),
1034 name,
1035 };
1036 snapshot.groups.push(group.clone());
1037 Ok(group)
1038 })
1039}
1040
1041#[lingxia::native("bookmarks.renameGroup")]
1042fn rename_group(app: Arc<LxApp>, input: GroupRenameInput) -> HostResult<BookmarkGroup> {
1043 crate::require_builtin_browser(&app)?;
1044 mutate(&app.app_data_dir(), |snapshot| {
1045 rename_group_op(snapshot, &input.id, &input.name)
1046 })
1047}
1048
1049#[lingxia::native("bookmarks.deleteGroup")]
1050fn delete_group(app: Arc<LxApp>, input: IdInput) -> HostResult<()> {
1051 crate::require_builtin_browser(&app)?;
1052 mutate(&app.app_data_dir(), |snapshot| {
1053 delete_group_op(snapshot, &input.id)
1054 })
1055}
1056
1057#[lingxia::native("bookmarks.reorderGroups")]
1058fn reorder_groups(app: Arc<LxApp>, input: OrderedIdsInput) -> HostResult<()> {
1059 crate::require_builtin_browser(&app)?;
1060 mutate(&app.app_data_dir(), |snapshot| {
1061 let ids: Vec<&str> = snapshot.groups.iter().map(|g| g.id.as_str()).collect();
1062 if !is_id_permutation(&ids, &input.ordered_ids) {
1063 return Err(LxAppError::InvalidParameter(
1064 "orderedIds must be a permutation of all group ids".to_string(),
1065 ));
1066 }
1067 snapshot.groups.sort_by_key(|g| {
1068 input
1069 .ordered_ids
1070 .iter()
1071 .position(|id| *id == g.id)
1072 .unwrap_or(usize::MAX)
1073 });
1074 Ok(())
1075 })
1076}
1077
1078#[lingxia::native("bookmarks.importHtml")]
1079async fn import_html_bookmarks(
1080 app: Arc<LxApp>,
1081 mut cancel: HostCancel,
1082) -> HostResult<Option<ImportResult>> {
1083 crate::require_builtin_browser(&app)?;
1084 let app_for_picker = app.clone();
1085 let is_chinese = display_locale_is_chinese();
1086 let selected = await_or_cancel(&mut cancel, async move {
1087 lingxia_service::file::choose_file(
1088 &*app_for_picker.runtime,
1089 ChooseFileRequest {
1090 multiple: false,
1091 filters: vec![FileDialogFilter {
1092 name: Some(
1093 if is_chinese {
1094 "书签 HTML"
1095 } else {
1096 "Bookmark HTML"
1097 }
1098 .to_string(),
1099 ),
1100 extensions: vec!["html".to_string(), "htm".to_string()],
1101 }],
1102 title: Some(
1103 if is_chinese {
1104 "导入书签"
1105 } else {
1106 "Import Bookmarks"
1107 }
1108 .to_string(),
1109 ),
1110 default_path: None,
1111 },
1112 )
1113 .await
1114 .map_err(|error| map_platform_error("bookmarks.importHtml", error))
1115 })
1116 .await?;
1117 if selected.canceled {
1118 return Ok(None);
1119 }
1120 let path = selected.paths.first().ok_or_else(|| {
1121 LxAppError::InvalidParameter("bookmark import did not return a file".to_string())
1122 })?;
1123 let metadata = std::fs::metadata(path)
1124 .map_err(|error| LxAppError::IoError(format!("read {path}: {error}")))?;
1125 if metadata.len() > MAX_IMPORT_FILE_BYTES {
1126 return Err(LxAppError::InvalidParameter(format!(
1127 "bookmark file exceeds the {} MB limit",
1128 MAX_IMPORT_FILE_BYTES / 1024 / 1024
1129 )));
1130 }
1131 let html = std::fs::read_to_string(path)
1132 .map_err(|error| LxAppError::IoError(format!("read {path}: {error}")))?;
1133 if !html
1134 .get(..html.len().min(1024))
1135 .unwrap_or(&html)
1136 .to_ascii_lowercase()
1137 .contains("netscape-bookmark-file-1")
1138 {
1139 return Err(LxAppError::InvalidParameter(
1140 "select a Netscape-format bookmark HTML file".to_string(),
1141 ));
1142 }
1143 let bookmarks = parse_netscape_html(&html);
1144 mutate(&app.app_data_dir(), move |snapshot| {
1145 Ok(merge_imported(snapshot, bookmarks))
1146 })
1147 .map(Some)
1148}
1149
1150#[lingxia::native("bookmarks.exportHtml")]
1151fn export_html_bookmarks(app: Arc<LxApp>) -> HostResult<ExportResult> {
1152 crate::require_builtin_browser(&app)?;
1153 let snapshot = {
1154 let _guard = store_lock()
1155 .lock()
1156 .unwrap_or_else(|error| error.into_inner());
1157 load(&app.app_data_dir())?
1158 };
1159 let directory = lingxia_service::downloads::dir(&app.app_data_dir());
1160 std::fs::create_dir_all(&directory)
1161 .map_err(|error| LxAppError::IoError(format!("mkdir {}: {error}", directory.display())))?;
1162 let path = unique_export_path(&directory);
1163 let (html, count) = export_netscape_html(&snapshot, now_ms());
1164 std::fs::write(&path, html)
1165 .map_err(|error| LxAppError::IoError(format!("write {}: {error}", path.display())))?;
1166 Ok(ExportResult {
1167 path: path.to_string_lossy().to_string(),
1168 file_name: path
1169 .file_name()
1170 .and_then(|name| name.to_str())
1171 .unwrap_or("bookmarks.html")
1172 .to_string(),
1173 count,
1174 })
1175}
1176
1177#[lingxia::native("bookmarks.watch", stream)]
1178async fn watch_bookmarks(
1179 app: Arc<LxApp>,
1180 mut stream: StreamContext<BookmarksView>,
1181) -> HostResult<()> {
1182 crate::require_builtin_browser(&app)?;
1183 let mut rx = channel().subscribe();
1184 {
1187 let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
1188 let snapshot = load(&app.app_data_dir())?;
1189 stream.send(bookmarks_view(snapshot))?;
1190 }
1191 loop {
1192 tokio::select! {
1193 _ = stream.canceled() => return Ok(()),
1194 recv = rx.recv() => {
1195 match recv {
1196 Ok(snapshot) => stream.send(bookmarks_view(snapshot))?,
1197 Err(broadcast::error::RecvError::Lagged(_)) => {
1198 let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
1200 let snapshot = load(&app.app_data_dir())?;
1201 stream.send(bookmarks_view(snapshot))?;
1202 }
1203 Err(broadcast::error::RecvError::Closed) => return stream.end(()),
1204 }
1205 }
1206 }
1207 }
1208}
1209
1210pub(crate) fn register() {
1211 lxapp::host::register_host_entry(list_bookmarks_host());
1212 lxapp::host::register_host_entry(add_bookmark_host());
1213 lxapp::host::register_host_entry(remove_bookmark_host());
1214 lxapp::host::register_host_entry(toggle_bookmark_route_host());
1215 lxapp::host::register_host_entry(bookmark_status_host());
1216 lxapp::host::register_host_entry(rename_bookmark_host());
1217 lxapp::host::register_host_entry(move_bookmark_host());
1218 lxapp::host::register_host_entry(reorder_bookmarks_host());
1219 lxapp::host::register_host_entry(set_pinned_host());
1220 lxapp::host::register_host_entry(create_group_host());
1221 lxapp::host::register_host_entry(rename_group_host());
1222 lxapp::host::register_host_entry(delete_group_host());
1223 lxapp::host::register_host_entry(reorder_groups_host());
1224 lxapp::host::register_host_entry(import_html_bookmarks_host());
1225 lxapp::host::register_host_entry(export_html_bookmarks_host());
1226 lxapp::host::register_host_entry(watch_bookmarks_host());
1227}
1228
1229#[cfg(test)]
1230mod tests {
1231 use super::*;
1232
1233 #[test]
1234 fn add_dedups_by_normalized_url() {
1235 let mut snapshot = BookmarksSnapshot::default();
1236 let (first, created) =
1237 add_entry(&mut snapshot, "https://example.com/", Some("Example"), None).unwrap();
1238 assert!(created);
1239 let (again, created) = add_entry(&mut snapshot, "https://EXAMPLE.com", None, None).unwrap();
1240 assert!(!created);
1241 assert_eq!(first.id, again.id);
1242 assert_eq!(snapshot.entries.len(), 1);
1243 }
1244
1245 #[test]
1246 fn add_defaults_title_to_host() {
1247 let mut snapshot = BookmarksSnapshot::default();
1248 let (entry, _) =
1249 add_entry(&mut snapshot, "https://docs.example.com/guide", None, None).unwrap();
1250 assert_eq!(entry.title, "docs.example.com");
1251 }
1252
1253 #[test]
1254 fn bookmark_view_projects_only_bookmark_pins_in_shell_order() {
1255 let mut snapshot = BookmarksSnapshot::default();
1256 let (first, _) = add_entry(&mut snapshot, "https://a.test", Some("A"), None).unwrap();
1257 let (second, _) = add_entry(&mut snapshot, "https://b.test", Some("B"), None).unwrap();
1258 let view = bookmarks_view_with_pins(
1259 snapshot,
1260 vec![
1261 lingxia_shell::ShellPin(lingxia_shell::ShellPinTarget::Bookmark {
1262 key: second.id.clone(),
1263 }),
1264 lingxia_shell::ShellPin(lingxia_shell::ShellPinTarget::Lxapp {
1265 key: "app.chat".to_string(),
1266 }),
1267 lingxia_shell::ShellPin(lingxia_shell::ShellPinTarget::Bookmark {
1268 key: first.id.clone(),
1269 }),
1270 ],
1271 );
1272
1273 assert_eq!(view.pinned_ids, vec![second.id, first.id]);
1274 }
1275
1276 #[test]
1277 fn add_rejects_unknown_group() {
1278 let mut snapshot = BookmarksSnapshot::default();
1279 assert!(add_entry(&mut snapshot, "https://a.test", None, Some("nope")).is_err());
1280 }
1281
1282 #[test]
1283 fn add_rejects_non_web_urls_and_missing_hosts() {
1284 let mut snapshot = BookmarksSnapshot::default();
1285 for url in [
1286 "javascript://alert(1)",
1287 "file:///tmp/private.txt",
1288 "https://",
1289 "https:///missing-host",
1290 ] {
1291 assert!(
1292 add_entry(&mut snapshot, url, None, None).is_err(),
1293 "unexpectedly accepted {url}"
1294 );
1295 }
1296 assert!(snapshot.entries.is_empty());
1297 }
1298
1299 #[test]
1300 fn permutation_rejects_duplicate_ids() {
1301 let expected = ["a", "b"];
1302 assert!(is_id_permutation(
1303 &expected,
1304 &["b".to_string(), "a".to_string()]
1305 ));
1306 assert!(!is_id_permutation(
1307 &expected,
1308 &["a".to_string(), "a".to_string()]
1309 ));
1310 assert!(!is_id_permutation(
1312 &["a", "a", "b"],
1313 &["a".to_string(), "b".to_string(), "b".to_string()]
1314 ));
1315 }
1316
1317 #[test]
1318 fn reorder_with_duplicate_ids_errors_instead_of_panicking() {
1319 let entry = |id: &str, url: &str| BookmarkEntry {
1320 id: id.to_string(),
1321 url: url.to_string(),
1322 title: String::new(),
1323 group_id: None,
1324 created_at_ms: 0,
1325 };
1326 let mut snapshot = BookmarksSnapshot {
1328 entries: vec![
1329 entry("a", "https://a.test"),
1330 entry("a", "https://a2.test"),
1331 entry("b", "https://b.test"),
1332 ],
1333 ..Default::default()
1334 };
1335 let result = reorder_entries(
1336 &mut snapshot,
1337 None,
1338 &["a".to_string(), "b".to_string(), "b".to_string()],
1339 );
1340 assert!(matches!(result, Err(LxAppError::InvalidParameter(_))));
1341 reorder_entries(
1343 &mut snapshot,
1344 None,
1345 &["b".to_string(), "a".to_string(), "a".to_string()],
1346 )
1347 .unwrap();
1348 assert_eq!(snapshot.entries[0].id, "b");
1349 }
1350
1351 #[test]
1352 fn rename_group_rejects_duplicate_names_for_every_caller() {
1353 let mut snapshot = BookmarksSnapshot {
1354 version: CURRENT_VERSION,
1355 groups: vec![
1356 BookmarkGroup {
1357 id: "g1".into(),
1358 name: "Work".into(),
1359 },
1360 BookmarkGroup {
1361 id: "g2".into(),
1362 name: "Personal".into(),
1363 },
1364 ],
1365 entries: Vec::new(),
1366 };
1367 let error = rename_group_op(&mut snapshot, "g2", "Work").unwrap_err();
1368 assert!(matches!(error, LxAppError::InvalidParameter(_)));
1369 assert_eq!(snapshot.groups[1].name, "Personal");
1370 }
1371
1372 #[test]
1373 fn chinese_locale_matches_webui_resolution() {
1374 for locale in ["zh", "zh-CN", "zh_CN", "ZH-Hans-CN"] {
1375 assert!(is_chinese_locale(locale), "expected Chinese: {locale}");
1376 }
1377 for locale in ["en-US", "zho", "ja-JP", ""] {
1378 assert!(!is_chinese_locale(locale), "expected non-Chinese: {locale}");
1379 }
1380 }
1381
1382 #[test]
1383 fn corrupt_store_recovers_with_default_snapshot() {
1384 let dir = tempfile::tempdir().unwrap();
1385 let path = bookmarks_path(dir.path());
1386 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1387 std::fs::write(&path, "{ truncated").unwrap();
1388 let snapshot = load(dir.path()).unwrap();
1389 assert!(snapshot.entries.is_empty());
1390 assert!(!path.exists());
1391 assert!(path.with_extension("json.corrupt").exists());
1392 }
1393
1394 #[test]
1395 fn import_merges_valid_bookmarks_and_preserves_existing_entries() {
1396 let mut snapshot = BookmarksSnapshot::default();
1397 add_entry(
1398 &mut snapshot,
1399 "https://existing.test",
1400 Some("Existing"),
1401 None,
1402 )
1403 .unwrap();
1404 let result = merge_imported(
1405 &mut snapshot,
1406 vec![
1407 ImportedBookmark {
1408 url: "https://EXISTING.test/".into(),
1409 title: "Duplicate".into(),
1410 group_name: Some("Imported".into()),
1411 created_at_ms: None,
1412 },
1413 ImportedBookmark {
1414 url: "javascript:alert(1)".into(),
1415 title: "Unsafe".into(),
1416 group_name: Some("Imported".into()),
1417 created_at_ms: None,
1418 },
1419 ImportedBookmark {
1420 url: "https://new.test/docs".into(),
1421 title: "New".into(),
1422 group_name: Some("Imported".into()),
1423 created_at_ms: Some(42_000),
1424 },
1425 ],
1426 );
1427 assert_eq!(
1428 result,
1429 ImportResult {
1430 imported: 1,
1431 skipped: 2,
1432 groups_created: 1,
1433 }
1434 );
1435 assert_eq!(snapshot.entries.len(), 2);
1436 assert_eq!(snapshot.groups.len(), 1);
1437 assert_eq!(snapshot.entries[1].created_at_ms, 42_000);
1438 }
1439}