Skip to main content

lingxia_browser_shell/
bookmarks.rs

1//! Browser bookmarks: persistent store + host routes + chrome-facing helpers.
2//!
3//! Persistence mirrors proxy settings: one JSON file in the app state
4//! directory (`browser-bookmarks.json`). Every mutation goes through the
5//! store lock (load → mutate → replace save) and broadcasts the new snapshot
6//! so `bookmarks.watch` subscribers (newtab, bookmarks page) re-render live
7//! when the native chrome toggles a star.
8
9use 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/// Bookmark persistence. Pin identity and order live in `lingxia-shell`.
50/// Entry order within the vec is the bookmark display order.
51#[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
117// MARK: - Store
118
119fn 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            // A corrupt file would otherwise fail every load forever; set it
147            // aside and recover with an empty store.
148            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
209/// Native-chrome change listener (e.g. macOS sidebar refresh). One per host.
210fn change_listener() -> &'static OnceLock<Box<dyn Fn() + Send + Sync>> {
211    static LISTENER: OnceLock<Box<dyn Fn() + Send + Sync>> = OnceLock::new();
212    &LISTENER
213}
214
215/// Register the chrome-side change callback; fires after every mutation.
216pub fn set_change_listener(listener: Box<dyn Fn() + Send + Sync>) {
217    let _ = change_listener().set(listener);
218}
219
220/// Load → mutate → save → broadcast, under the store lock.
221fn 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
294// MARK: - Shared store mutations
295// Host routes and native-chrome `command_json` both dispatch through these,
296// so the two entry points cannot drift apart.
297
298fn 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    // Orphaned entries fall back to ungrouped rather than disappearing.
367    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
377// MARK: - Chrome-facing helpers (no LxApp, e.g. from Swift FFI)
378
379fn runtime_data_dir() -> Option<PathBuf> {
380    lxapp::get_platform().map(|runtime| runtime.app_data_dir())
381}
382
383/// Is `url` bookmarked? `false` when the runtime is not up or the store is
384/// unreadable — the chrome star simply shows unstarred.
385pub 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
396/// Full snapshot as JSON for native chrome (sidebar bookmarks section).
397pub 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
404/// Stable comparison key used by native chrome when matching a stored
405/// bookmark to an open tab.
406pub fn normalize_url_for_match(raw: &str) -> String {
407    normalize_url(raw)
408}
409
410/// Typed bookmark snapshot for native shell chrome. Pin identity and order
411/// come from `lingxia-shell`, not this bookmark store.
412pub 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
418/// Absolute path to Rust's cached favicon for `url`. A missing or stale cache
419/// entry is refreshed asynchronously; native chrome is notified when ready.
420pub 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
431/// Store favicon bytes the platform webview already resolved (page-declared
432/// icon link) into the shared cross-platform cache — the ONE favicon store
433/// pins, sidebar tab rows, and the docked browser all read — and wake native
434/// chrome so pin tiles pick it up.
435pub 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
447/// Pin `url` to the sidebar grid ("Pin to Sidebar" on a tab). Bookmarks the
448/// page first when needed, keeping the pinned ⊆ bookmarked invariant without
449/// forcing a two-step flow on the user.
450fn 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
476/// Pin a URL and cache its current favicon outside the bookmark JSON.
477pub 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
509/// Remove `url` from bookmarks (sidebar row action). True when removed.
510pub 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
535/// One in-place management command from native chrome (sidebar menus), as
536/// JSON: `{"op": "rename" | "move" | "createGroupAndMove" | "renameGroup" |
537/// "deleteGroup", ...}`. Returns false on any error.
538pub 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
609/// Toggle `url` (chrome star / ⌘D). Returns the new bookmarked state, or
610/// `None` when the store is unavailable.
611pub 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// MARK: - Host routes
638
639#[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
740/// File-picker labels follow the product display language:
741/// `lxapp::get_display_language` resolves the user override behind
742/// `settings.getLanguage`, else the system locale — exactly like the webui
743/// i18n resolution.
744fn display_locale_is_chinese() -> bool {
745    is_chinese_locale(&lxapp::get_display_language())
746}
747
748fn is_chinese_locale(language: &str) -> bool {
749    // Mirrors the webui's /^zh(?:-|$)/ test (plus "_" for POSIX locale ids).
750    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    // Compare as multisets: a set comparison lets duplicated ids slip through.
840    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    // Reorder only the entries in the given group scope; entries in other
853    // scopes keep their relative positions.
854    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    // Queue per id so duplicated ids in a damaged store still pop one entry each.
867    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        // Both iterators walk the same scope, so `ordered` cannot run dry.
878        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    // Seed subscribers with the current state so the page renders from the
1185    // stream alone.
1186    {
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                        // Snapshots are self-contained; resync from disk.
1199                        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        // Duplicates on both sides fool a set comparison; multisets must not.
1311        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        // Damaged store: two entries share an id.
1327        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        // A matching multiset still reorders cleanly.
1342        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}