跳转至

crates\nfarms-core\src\store.rs

源码路径:crates\nfarms-core\src\store.rs · 998 行

//! 一个小 JSON 键值存储 —— 书签、上次打开的文件、同步状态都放它里面。
//!
//! 为什么不直接上 SQLite:C# 的 `Store.cs` 是一套「优先 SQLite、找不到就回退 JSON」
//! 的**反射加载**方案(`Assembly.LoadFrom` + 找 `System.Data.SQLite.dll`),
//! 那是 .NET 特有的绕路。Rust 这边真要 SQLite 就引 `rusqlite`(带 C 源码编译),
//! 但那是个几十 MB 的构建代价 —— 而这里要存的东西(书签几十条、几个路径)
//! 用 JSON 完全够,而且**人能直接打开看、能手工修**。
//!
//! 所以:JSON 优先,接口留成 key/value。以后真要 SQLite,换掉这个文件的实现即可,
//! 调用方不用动。

use std::path::{Path, PathBuf};

use serde_json::{Map, Value, json};

use crate::Paths;

/// 存储文件名(和 settings.json 一样放在**程序根**,即 `paths.src()` 指的目录)。
///
/// 这里以前写的是「放在 `src\` 下」—— 那是 C# 原版的布局,`Paths::src()` 现在
/// 直接返回程序根,`src\` 这一层早就取消了。留着旧说法会让人照着注释去 `src\`
/// 找一个永远不存在的 `store.json`。
pub const FILE: &str = "store.json";

// ---------------------------------------------------------------- 端口
//
// 本机几个服务的端口是**运行配置**(改了要记住、重启后还认),所以住在
// `store.json`,**不进 `settings.json`**:后者是要和 C# 原版逐字节对齐的那份
// 文件,往里加字段会直接弄红 `nfarms-core::settings` 的逐字节护栏
// (见 settings.rs 模块头那段"原版没有的字段必须挂 `skip_serializing_if`")。
//
// **配对项 —— 改一个必须同时改另一个。** `vm_port` / `loki_port` 不只是本程序
// 拿去连 VictoriaMetrics / Loki 用的:这两个端口还要**原样出现在 Grafana 的
// provision 文件里**(数据源 URL),那份由 `nfarms-core::observability` 生成。
// 只改了这里、忘了那份,症状是 Grafana 能起来但**数据源连不上** ——
// provision 里还指着旧端口,而 Grafana 的日志只会说一句 connection refused,
// 从这句话看不出是端口对不上。
//
// 默认值 = 各服务自己的监听端口(统一迁移后的那一套)。它**只决定初值**:
// 读的一侧只在键缺失或值写坏时回落到默认,**绝不覆盖用户已经存过的值** ——
// 用户盘上写着 8080,读回来就得是 8080,迁移不动老用户的配置。

/// llama.cpp 的默认端口(旧的 8080 已统一迁移到 9094)。
pub const DEFAULT_LLAMA_PORT: u16 = 9094;
/// sd.cpp 的默认端口(旧的 1234 已统一迁移到 9095)。
pub const DEFAULT_SD_PORT: u16 = 9095;
/// VictoriaMetrics 的默认端口。
pub const DEFAULT_VM_PORT: u16 = 9091;
/// Loki 的默认端口。
pub const DEFAULT_LOKI_PORT: u16 = 9092;
/// Grafana 的默认端口。
pub const DEFAULT_GRAFANA_PORT: u16 = 9093;

/// `store.json` 里端口用的键名。
///
/// 键名是**磁盘上的契约**(用户会打开这个文件手改),抽成常量给读写两侧共用:
/// 打错一个字母的后果是"用户的设置不见了、悄悄回到默认",而盘上那份值还好端端
/// 躺在一个没人读的键下面。`llama_port` / `sd_port` 这两个键**已经在盘上了**
/// (见 `nfarms-gui` 的 `LocalModel::save_ports`),不能改名。
pub const KEY_LLAMA_PORT: &str = "llama_port";
pub const KEY_SD_PORT: &str = "sd_port";
pub const KEY_VM_PORT: &str = "vm_port";
pub const KEY_LOKI_PORT: &str = "loki_port";
pub const KEY_GRAFANA_PORT: &str = "grafana_port";

/// 一条书签。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Bookmark {
    pub title: String,
    pub url: String,
    /// 加入时间(`yyyy-MM-dd HH:mm:ss`)
    pub added: String,
}

impl Bookmark {
    pub fn to_json(&self) -> Value {
        json!({ "title": self.title, "url": self.url, "added": self.added })
    }
    pub fn from_json(v: &Value) -> Option<Self> {
        let o = v.as_object()?;
        Some(Self {
            title: o.get("title")?.as_str()?.to_string(),
            url: o.get("url")?.as_str()?.to_string(),
            added: o
                .get("added")
                .and_then(|x| x.as_str())
                .unwrap_or_default()
                .to_string(),
        })
    }
}

pub struct Store {
    pub path: PathBuf,
    data: Map<String, Value>,
    /// 载入时如果文件坏了,这里留一句说明 —— 界面上要如实告诉用户,
    /// 而不是悄悄把旧数据覆盖掉。
    pub load_note: Option<String>,
}

impl Store {
    /// 默认位置:`<程序根>\store.json`。
    ///
    /// 注意是程序根,**不是** `src\`:`Paths::src()` 自己就返回程序根(见 `paths.rs`),
    /// 所以这里的 `paths.src()` 没有"少拼一层"。改这句注释前先看一眼 `Paths::src()`。
    pub fn default_path(paths: &Paths) -> PathBuf {
        paths.src().join(FILE)
    }

    pub fn load(path: &Path) -> Self {
        let mut me = Self {
            path: path.to_path_buf(),
            data: Map::new(),
            load_note: None,
        };
        match std::fs::read_to_string(path) {
            Ok(text) => {
                // 和 settings.json 一样要吞 BOM:别的编辑器另存可能带上
                let text = crate::strip_bom(&text);
                match serde_json::from_str::<Value>(text) {
                    Ok(Value::Object(o)) => {
                        me.data = o;
                        // 文件本身能解析,但里面的书签条目可能是坏的 ——
                        // 那不会被上面两个分支发现,却会让界面上凭空少东西。
                        me.note_bookmark_damage();
                    }
                    Ok(_) => {
                        me.load_note = Some(format!("{} 的顶层不是对象,已忽略", path.display()))
                    }
                    Err(e) => {
                        me.load_note = Some(format!(
                            "{} 解析失败({e}),本次以空存储启动 —— 原文件没有被覆盖,可以手工修",
                            path.display()
                        ))
                    }
                }
            }
            Err(_) => { /* 文件不存在就是全新的,不算错误 */ }
        }
        me
    }

    /// 载入后对 `bookmarks` 做一次体检,把「界面里会凭空少东西」的情况记进 `load_note`。
    ///
    /// 为什么在这里查、而不是在 `bookmarks()` 里查:`bookmarks()` 收 `&self`,
    /// 而且界面每帧都可能调它;要在那里改 `load_note` 就得把签名改成 `&mut self`,
    /// 为了记一句"载入时看到过坏条目"把只读访问升级成可变访问,得不偿失。
    /// 坏条目只可能从**文件**里进来(`set_bookmarks` 写出去的永远是合法条目),
    /// 所以载入是唯一该查、也是唯一能查全的时刻。
    ///
    /// 不这么做会怎样:`bookmarks()` 的 `filter_map` 会把格式不对的条目**无声**丢掉
    /// (比如某条的 `url` 被手改成了数字),用户看到书签少了却不知道为什么;
    /// 更糟的是下一次加/删书签会把过滤后的列表 `set_bookmarks` 回去,坏条目
    /// 连在文件里都消失 —— 那时候再想找回就真没了。
    fn note_bookmark_damage(&mut self) {
        // 先把结论算完再动 `self`:一边借着 `self.data` 一边写 `self.load_note`
        // 借用检查器不会放行,硬写就得先 clone 一份数据,不值当。
        let mut broken = 0usize;
        let mut undated = 0usize;
        let mut not_array = false;
        match self.data.get("bookmarks") {
            None => return,
            Some(Value::Array(arr)) => {
                for v in arr {
                    match Bookmark::from_json(v) {
                        Some(b) => {
                            if b.added.is_empty() {
                                undated += 1;
                            }
                        }
                        None => broken += 1,
                    }
                }
            }
            Some(_) => not_array = true,
        }
        if not_array {
            self.push_note("bookmarks 不是数组,本次一个书签都读不出来(原文件没动)");
            return;
        }
        if broken > 0 {
            self.push_note(&format!(
                "有 {broken} 条书签格式不对(title / url 缺失或不是字符串),界面里看不到它们"
            ));
        }
        // `added` 缺失时 `Bookmark::from_json` 填空串,界面据此隐藏「加入时间」那一列。
        // **这个容忍行为本身是对的**:回填一个"本次启动时间"是编数据,比不显示更糟。
        // 但得如实告诉用户"这几条查不到什么时候加的",否则他只会以为界面坏了。
        if undated > 0 {
            self.push_note(&format!(
                "有 {undated} 条书签没有加入时间,界面不显示该列"
            ));
        }
    }

    /// 往 `load_note` 追加一句。多条问题要逐条列出,不能只留最后一条 ——
    /// 「坏文件」和「坏书签」同时发生时,少说一句用户就少知道一件事。
    fn push_note(&mut self, msg: &str) {
        match &mut self.load_note {
            Some(note) => {
                note.push(';');
                note.push_str(msg);
            }
            None => self.load_note = Some(msg.to_string()),
        }
    }

    pub fn get_str(&self, key: &str) -> Option<String> {
        self.data.get(key)?.as_str().map(|s| s.to_string())
    }

    pub fn set_str(&mut self, key: &str, value: &str) {
        self.data
            .insert(key.to_string(), Value::String(value.to_string()));
    }

    pub fn remove(&mut self, key: &str) {
        self.data.remove(key);
    }

    /// 写回磁盘(缩进 2 空格,给人看的)。
    ///
    /// **先写 `store.json.tmp`,写完再 `rename` 顶替原文件。**
    ///
    /// 为什么不能直接 `fs::write` 覆盖:`fs::write` 是先**截断**目标文件再往里写,
    /// 截断与写完之间有个窗口。断电、任务管理器结束进程、磁盘满,任意一种落在这个
    /// 窗口里,盘上留下的就是半截 JSON —— 而半截 JSON 在下一次启动时走的是
    /// 「解析失败 → 空存储」那条路:**不报错、不崩,但内容全没了**,
    /// 用户根本不会知道自己丢了东西。
    ///
    /// `rename` 在同一个目录内是原子的(Windows 上 std 走
    /// `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`),所以任何时刻磁盘上要么是
    /// 完整的旧内容、要么是完整的新内容,不存在"半截"这个状态。
    /// 代价是同目录下多一次写,这个文件的体量(几 KB)可以忽略。
    pub fn save(&self) -> Result<(), String> {
        if let Some(parent) = self.path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| format!("建目录失败: {e}"))?;
        }
        // **覆盖之前,先把"读不懂的原文件"留一份。**
        //
        // 场景:用户手工改坏了 store.json(少个引号、多个逗号)—— `load`
        // 解析失败、退回空存储。这时他随手指点一下(加书签 / 拖侧栏 / 改端口)
        // 就会走到这里。**不备份的话,他一整份手写的内容被一个空存储顶掉,
        // 而且没有任何提示** —— 那是不可恢复的数据丢失。
        //
        // 判据是「**磁盘上那份**能不能解析」,而不是「本次 load 有没有失败」:
        // save 可能是另一个实例发起的,那个实例未必 load 过这份坏文件。
        if self.path.is_file() {
            let existing = std::fs::read_to_string(&self.path).unwrap_or_default();
            let parseable = serde_json::from_str::<Value>(&existing)
                .map(|v| v.is_object())
                .unwrap_or(false);
            if !parseable && !existing.trim().is_empty() {
                let bak = std::path::PathBuf::from(format!("{}.bak", self.path.display()));
                // **备份失败就不写。** 宁可这次操作丢掉(用户可以重做),
                // 也不能把原文件毁了 —— 那是不可逆的。
                std::fs::copy(&self.path, &bak).map_err(|e| {
                    format!(
                        "原文件读不懂,备份到 {} 失败: {e} —— 已放弃本次写入,原文件没动",
                        bak.display()
                    )
                })?;
            }
        }
        // `serde_json::Map<String, Value>` 自己就实现了 `Serialize`,而
        // `to_string_pretty` 收的也是 `&T` —— 旧写法 `&Value::Object(self.data.clone())`
        // 白复制了一整份数据(书签几百条时就是几百次 Value 的深拷贝)。
        // 输出字节完全一样:Map 序列化出来就是那个对象。
        let text = serde_json::to_string_pretty(&self.data)
            .map_err(|e| format!("序列化失败: {e}"))?;

        // 临时文件放在**同一个目录**里,不能放 `%TEMP%`:跨盘 / 跨卷的 rename
        // 不是原子的,Windows 上会直接失败(ERROR_NOT_SAME_DEVICE)。
        let tmp = PathBuf::from(format!("{}.tmp", self.path.display()));
        if let Err(e) = std::fs::write(&tmp, &text) {
            // 写坏的临时文件要清掉:没人会去清理它,用户看到 `store.json.tmp`
            // 只会更困惑 "这是不是我的数据"。
            let _ = std::fs::remove_file(&tmp);
            return Err(format!("写临时文件 {} 失败: {e}", tmp.display()));
        }
        std::fs::rename(&tmp, &self.path).map_err(|e| {
            // 替换失败时原文件**一个字都没动**(rename 要么成功要么什么也没发生),
            // 所以这里可以放心报"原文件没动";半截的临时文件同样不能留下。
            let _ = std::fs::remove_file(&tmp);
            format!("替换 {} 失败: {e} —— 原文件没动", self.path.display())
        })
    }

    // ---------------------------------------------------------------- 端口
    //
    // 常量与「配对项」的说明在文件顶部那个「端口」小节(默认值 9094 / 9095 /
    // 9091 / 9092 / 9093)。这里只放**读写口子**:每个端口都是一读一写一对,
    // 和 `set_bookmarks` / `nav_order` 那几组一样 —— 只有字段没有入口,
    // 界面就永远存不进去,那等于没做。

    /// 从 `store.json` 读一个端口。**只读,不写回**:键缺失或值写坏时返回
    /// `default`,但绝不把默认值塞进 `data` —— 读设置的动作不该有副作用,
    /// 更不该在用户什么都没改的时候改写磁盘内容。
    fn get_port(&self, key: &str, default: u16) -> u16 {
        // 两种写法都认:字符串 `"9094"`(本程序自己写出去的格式,与盘上已有的
        // `llama_port` / `sd_port` 一致)和数字 `9094`(用户手改时更自然的写法)。
        // 只认一种的后果是另一种被当成"没设过"、静默回落到默认 ——
        // 用户改了端口却发现不起作用,而文件里明明写着。
        match self.data.get(key) {
            Some(Value::String(s)) => s.trim().parse().unwrap_or(default),
            Some(v) => v
                .as_u64()
                .and_then(|n| u16::try_from(n).ok())
                .unwrap_or(default),
            None => default,
        }
    }

    /// 写一个端口。**只改内存,落盘要再调 `save()`**(与 `set_bookmarks` 等
    /// 其它 setter 一致)。
    ///
    /// 存成字符串而不是数字:与盘上已有的 `llama_port` / `sd_port` 写法一致
    /// (`LocalModel::save_ports` 写的就是字符串)。同一个文件里同一种东西两种
    /// 类型,下次谁按 JSON 类型去判断就会漏掉一半。
    fn set_port(&mut self, key: &str, port: u16) {
        self.set_str(key, &port.to_string());
    }

    /// llama.cpp 的端口。没存过时是 `DEFAULT_LLAMA_PORT`(9094);
    /// 存过就原样读回 —— 包括老用户盘上的 8080,迁移不替他改。
    pub fn llama_port(&self) -> u16 {
        self.get_port(KEY_LLAMA_PORT, DEFAULT_LLAMA_PORT)
    }

    pub fn set_llama_port(&mut self, port: u16) {
        self.set_port(KEY_LLAMA_PORT, port);
    }

    /// sd.cpp 的端口。没存过时是 `DEFAULT_SD_PORT`(9095);存过就原样读回。
    pub fn sd_port(&self) -> u16 {
        self.get_port(KEY_SD_PORT, DEFAULT_SD_PORT)
    }

    pub fn set_sd_port(&mut self, port: u16) {
        self.set_port(KEY_SD_PORT, port);
    }

    /// VictoriaMetrics 的端口。没存过时是 `DEFAULT_VM_PORT`(9091)。
    ///
    /// **配对项**:这个数还要出现在 Grafana 的 provision 文件里
    /// (见文件顶部那段),改这里必须同时改那份。
    pub fn vm_port(&self) -> u16 {
        self.get_port(KEY_VM_PORT, DEFAULT_VM_PORT)
    }

    pub fn set_vm_port(&mut self, port: u16) {
        self.set_port(KEY_VM_PORT, port);
    }

    /// Loki 的端口。没存过时是 `DEFAULT_LOKI_PORT`(9092)。
    ///
    /// **配对项**:与 `vm_port` 同理,必须和 Grafana provision 文件里的数据源
    /// URL 保持一致。
    pub fn loki_port(&self) -> u16 {
        self.get_port(KEY_LOKI_PORT, DEFAULT_LOKI_PORT)
    }

    pub fn set_loki_port(&mut self, port: u16) {
        self.set_port(KEY_LOKI_PORT, port);
    }

    /// Grafana 自己的端口。没存过时是 `DEFAULT_GRAFANA_PORT`(9093)。
    ///
    /// 这一个不是"配对项的左边"而是**被指向的那一侧**:provision 文件里写的是
    /// VM / Loki 的端口,不是 Grafana 自己的。
    pub fn grafana_port(&self) -> u16 {
        self.get_port(KEY_GRAFANA_PORT, DEFAULT_GRAFANA_PORT)
    }

    pub fn set_grafana_port(&mut self, port: u16) {
        self.set_port(KEY_GRAFANA_PORT, port);
    }

    // ---------------------------------------------------------- 书签
    //
    // 书签单独放一个键下面(`bookmarks`),这样整个文件还能放别的东西,
    // 而书签这一块能整体替换 —— 比按 id 散落一地好管。

    pub fn bookmarks(&self) -> Vec<Bookmark> {
        self.data
            .get("bookmarks")
            .and_then(|v| v.as_array())
            .map(|a| a.iter().filter_map(Bookmark::from_json).collect())
            .unwrap_or_default()
    }

    pub fn set_bookmarks(&mut self, list: &[Bookmark]) {
        self.data.insert(
            "bookmarks".to_string(),
            Value::Array(list.iter().map(|b| b.to_json()).collect()),
        );
    }

    /// 书签判重用的比较键 —— 只抹掉「确定是同一份地址」的写法差异。
    ///
    /// 做三件事:去掉首尾空白、去掉结尾的 `/`、把 **scheme 与 host** 转小写。
    ///
    /// 为什么**不**把整串转小写(只转 scheme/host):`/Report` 和 `/report`
    /// 在多数 HTTP 服务器上是两个不同的资源,整串小写会把两条不同的书签并成一条,
    /// 用户就再也加不进第二条了 —— 那是"去重"变成"丢数据"。
    /// scheme 与 host 大小写不敏感是 RFC 3986 明写的,只转这两段不可能误并。
    ///
    /// 不是 URL 的串(用户存本地路径之类)没有 scheme/host 可分,退化成
    /// 「去空白 + 去结尾分隔符 + 整串小写」:路径本来就不该靠大小写区分,
    /// 这里小写是为了让 `C:\Docs` 和 `c:\docs` 不至各占一条。
    fn dedup_key(url: &str) -> String {
        let trimmed = url.trim().trim_end_matches(['/', '\\']);
        match trimmed.split_once("://") {
            Some((scheme, rest)) => {
                // host 到第一个 `/` 为止,后面全是路径(路径保持原样大小写)。
                let (authority, path) = match rest.find('/') {
                    Some(i) => (&rest[..i], &rest[i..]),
                    None => (rest, ""),
                };
                format!(
                    "{}://{}{}",
                    scheme.to_ascii_lowercase(),
                    authority.to_ascii_lowercase(),
                    path
                )
            }
            None => trimmed.to_ascii_lowercase(),
        }
    }

    /// 加一条。同一个 URL 只留一条(重复添加通常是点快了)。
    ///
    /// 判重按**归一化后的键**,不是按原文比:从地址栏复制来的 `https://a/`
    /// 和手打的 `https://a` 是同一条,按原文比会各存一份,书签列表里就出现
    /// 两条一模一样的条目,删一条还剩一条。
    ///
    /// 存进列表的仍然是 `url` **原文** —— 归一化只用于比较,不能拿它去改用户写的东西
    /// (`from_json`/界面上显示的就是用户当初粘的那串)。
    pub fn add_bookmark(&mut self, title: &str, url: &str, now: &str) -> bool {
        let mut list = self.bookmarks();
        let key = Self::dedup_key(url);
        if list.iter().any(|b| Self::dedup_key(&b.url) == key) {
            return false;
        }
        list.push(Bookmark {
            title: if title.trim().is_empty() {
                url.to_string()
            } else {
                title.trim().to_string()
            },
            url: url.to_string(),
            added: now.to_string(),
        });
        self.set_bookmarks(&list);
        true
    }

    /// 删一条。**只删一条**;先按原文精确匹配,找不到再按归一化键匹配。
    ///
    /// 为什么比较要用和 `add_bookmark` 同一套归一化键:否则会出现「加不进去、
    /// 也删不掉」的条目 —— 存的是 `https://a/`,用户拿 `https://a` 来删,
    /// 精确比较会说不存在,而 add 那边又认为它已经在列表里了。
    ///
    /// 为什么**先精确、再用键兜底**,而不是直接 `retain` 掉所有同键条目:
    /// 这一版的旧文件里可能已经同时存着 `https://a` 和 `https://a/`
    /// (旧版 add 就是这么存的),它们归一化后同键。界面上一次"删除"是删**一行**,
    /// 一把删掉两行是不可解释的 —— 用户会以为程序吃了他另一条书签。
    /// 精确命中时删的就是他点的那一条,不会波及同键的另一条。
    pub fn remove_bookmark(&mut self, url: &str) -> bool {
        let mut list = self.bookmarks();
        let key = Self::dedup_key(url);
        let hit = list
            .iter()
            .position(|b| b.url == url)
            .or_else(|| list.iter().position(|b| Self::dedup_key(&b.url) == key));
        match hit {
            Some(i) => {
                list.remove(i);
                self.set_bookmarks(&list);
                true
            }
            None => false,
        }
    }

    // ------------------------------------------------------ 侧栏顺序
    //
    // 存 **页面 id 的数组**,不是下标。
    //
    // 存下标会在升级后错乱 —— 这个项目本身就加过页(会话里从 30 页加到 34 页),
    // 下标一挪全部对不上;id("ffmpeg" / "log-path")天然稳定,页面删了也只是
    // 找不到、被忽略(见 `pages::apply_order`)。
    //
    // 为什么不开一个单独的 order.json:这个文件的定位就是「本程序自己的、
    // 不与 C# 原版对齐的键值存储」,bookmarks / wiki_sources 已经在这了,
    // 再多一个键比多一个文件好管。

    /// 侧栏顺序。空 = 用功能树默认顺序。
    pub fn nav_order(&self) -> Vec<String> {
        self.data
            .get("nav_order")
            .and_then(|v| v.as_array())
            .map(|a| {
                a.iter()
                    .filter_map(|x| x.as_str().map(|s| s.to_string()))
                    .collect()
            })
            .unwrap_or_default()
    }

    pub fn set_nav_order(&mut self, ids: &[String]) {
        self.data.insert(
            "nav_order".to_string(),
            Value::Array(ids.iter().map(|s| Value::String(s.clone())).collect()),
        );
    }
    // ---------------------------------------------------- 知识库索引路径
    //
    // 「自定义索引路径」= 用户指定**哪些目录**要被收进本地知识库。
    //
    // 存的是**原样字符串**(不规范化为绝对路径),理由:用户可能想写相对程序根的
    // 路径(比如 `docs`、`..\notes`),在这里强行 canonicalize 会把
    // 「程序搬个位置」变成「索引全部失效」。真正的解析放到建索引那一步 ——
    // 那里才知道程序根在哪。
    //
    // 比较时忽略结尾分隔符:免得 `docs` 和 `docs\` 各存一份。
    //
    // **现状(审计已确认):这一组接口全工程没有调用方。** GUI 里没有「自定义索引
    // 路径」界面,`kb.rs` 收的是程序根下写死的 `wiki\` 目录。要收尾只有两条路:
    // 在界面侧(`core_pages.rs` 的知识库页)补上入口并让 `kb.rs` 读这个列表,
    // 或者把这一整段删掉 —— "只写了接口、没接界面"本身就是半截契约。
    // 现在还留在这里的原因:接界面动的是 `kb.rs` / `core_pages.rs`,属于功能立项,
    // 不是这个文件能独立收尾的改动。改这里之前先确认那条路选了哪一条。

    fn norm_source(s: &str) -> String {
        s.trim().trim_end_matches(['\\', '/']).to_string()
    }

    /// 要索引的源目录(顺序 = 用户添加顺序)。
    pub fn wiki_sources(&self) -> Vec<String> {
        self.data
            .get("wiki_sources")
            .and_then(|v| v.as_array())
            .map(|a| {
                a.iter()
                    .filter_map(|x| x.as_str().map(|s| s.to_string()))
                    .collect()
            })
            .unwrap_or_default()
    }

    pub fn set_wiki_sources(&mut self, list: &[String]) {
        self.data.insert(
            "wiki_sources".to_string(),
            Value::Array(list.iter().map(|s| Value::String(s.clone())).collect()),
        );
    }

    /// 加一个索引路径。空串和重复都拒绝(返回 false)。
    pub fn add_wiki_source(&mut self, dir: &str) -> bool {
        let d = Self::norm_source(dir);
        if d.is_empty() {
            return false;
        }
        let mut list = self.wiki_sources();
        if list.iter().any(|x| Self::norm_source(x) == d) {
            return false;
        }
        list.push(dir.trim().to_string());
        self.set_wiki_sources(&list);
        true
    }

    pub fn remove_wiki_source(&mut self, dir: &str) -> bool {
        let target = Self::norm_source(dir);
        let mut list = self.wiki_sources();
        let before = list.len();
        list.retain(|x| Self::norm_source(x) != target);
        let changed = list.len() != before;
        if changed {
            self.set_wiki_sources(&list);
        }
        changed
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn tmp(name: &str) -> PathBuf {
        let d = std::env::temp_dir().join(format!("nfarms-store-{name}"));
        let _ = std::fs::remove_dir_all(&d);
        let _ = std::fs::create_dir_all(&d);
        d.join(FILE)
    }

    #[test]
    fn missing_file_is_not_an_error() {
        let s = Store::load(&tmp("missing"));
        assert!(s.load_note.is_none(), "文件不存在就是全新的,不该报错");
        assert!(s.bookmarks().is_empty());
    }

    #[test]
    fn roundtrip_survives_save_and_load() {
        let p = tmp("roundtrip");
        let mut s = Store::load(&p);
        assert!(s.add_bookmark("NFarms", "https://nfarms.cn/", "2026-10-03 09:00:00"));
        s.set_str("last_dir", r"C:\video");
        s.save().unwrap();

        let s2 = Store::load(&p);
        assert_eq!(s2.bookmarks().len(), 1);
        assert_eq!(s2.bookmarks()[0].title, "NFarms");
        assert_eq!(s2.get_str("last_dir").unwrap(), r"C:\video");
    }

    #[test]
    fn duplicate_url_is_ignored_and_title_falls_back() {
        let p = tmp("dup");
        let mut s = Store::load(&p);
        assert!(s.add_bookmark("", "https://a/", "t"));
        assert_eq!(s.bookmarks()[0].title, "https://a/", "标题空就用 URL 顶上");
        assert!(!s.add_bookmark("另一个名字", "https://a/", "t"));
        assert_eq!(s.bookmarks().len(), 1);
    }

    #[test]
    fn remove_reports_whether_it_changed_anything() {
        let p = tmp("remove");
        let mut s = Store::load(&p);
        s.add_bookmark("a", "https://a/", "t");
        assert!(s.remove_bookmark("https://a/"));
        assert!(!s.remove_bookmark("https://a/"), "删第二次应该返回 false");
        assert!(s.bookmarks().is_empty());
    }

    #[test]
    fn broken_file_is_reported_and_not_overwritten_implicitly() {
        let p = tmp("broken");
        std::fs::write(&p, "{ this is not json").unwrap();
        let s = Store::load(&p);
        assert!(s.load_note.is_some(), "坏文件必须被报出来");
        // 只 load 不 save:原文件应当原样还在
        assert!(std::fs::read_to_string(&p).unwrap().contains("not json"));
    }

    #[test]
    fn broken_file_is_backed_up_before_any_save_overwrites_it() {
        let p = tmp("broken-then-save");
        // 故意用「看着像 JSON、其实解析不过」的坏内容 —— 手工改坏的 store.json
        // 十有八九长这样(少个引号、多个逗号),而不是上一测试里那种一眼假的乱码。
        let broken = "{ \"bookmarks\": [ { \"title\": \"没关引号 } ] }";
        std::fs::write(&p, broken).unwrap();

        // 模拟界面:启动时 load 拿到坏文件,用户随后随便点了个会落盘的操作(加书签 / 拖侧栏 / 改端口)
        let mut s = Store::load(&p);
        assert!(s.load_note.is_some(), "坏文件必须被报出来");
        s.add_bookmark("NFarms", "https://nfarms.cn/", "2026-10-14 21:00:00");
        s.save().unwrap();

        // 这一条是本次缺陷的核心:坏文件的原内容必须还留在磁盘上,不能被空存储悄悄顶掉
        let bak = PathBuf::from(format!("{}.bak", p.display()));
        assert!(
            bak.is_file(),
            "解析失败时先把原文件留一份 .bak;现在 {} 不见了,原内容已被覆盖",
            bak.display()
        );
        assert_eq!(
            std::fs::read_to_string(&bak).unwrap(),
            broken,
            ".bak 必须是坏文件的逐字节副本,不能是重新序列化过的内容"
        );

        // 新存储照常写出去(用户的操作不能白点),而且是能读回来的
        let s2 = Store::load(&p);
        assert!(s2.load_note.is_none(), "覆盖后应当是一份正常的存储");
        assert_eq!(s2.bookmarks().len(), 1);
    }

    #[test]
    fn bom_prefixed_file_loads() {
        let p = tmp("bom");
        std::fs::write(&p, "\u{feff}{\"a\":\"b\"}").unwrap();
        let s = Store::load(&p);
        assert!(s.load_note.is_none());
        assert_eq!(s.get_str("a").unwrap(), "b");
    }

    // --------------------------------------------------------- 原子写

    /// `save` 必须落成文件、且不留下临时文件。
    #[test]
    fn save_lands_the_file_and_leaves_no_temp_behind() {
        let p = tmp("atomic-litter");
        let mut s = Store::load(&p);
        s.set_str("k", "v");
        s.save().unwrap();

        assert!(p.is_file(), "save 之后目标文件必须存在");
        assert!(
            !PathBuf::from(format!("{}.tmp", p.display())).exists(),
            "save 成功之后不能留下 store.json.tmp —— 下次启动没人会去清理它"
        );
        let on_disk = std::fs::read_to_string(&p).unwrap();
        assert!(
            serde_json::from_str::<Value>(&on_disk).is_ok(),
            "落盘的必须是一份完整 JSON:{on_disk}"
        );
    }

    /// **这条是"非原子写"的复发哨兵。**
    ///
    /// 目标位置放一个**目录**:`rename` 顶不上去(Windows 上 `MoveFileExW` 不允许
    /// 用文件替换目录),正好用来考"替换失败"这条路。断言里那句错误文案是刻意的 ——
    /// 旧实现直接 `fs::write(&self.path, text)`,失败时只会说"写失败",根本走不到
    /// 替换这一步;谁把 `save` 改回直接覆盖,这条立刻变红。
    #[test]
    fn a_failed_replace_keeps_the_old_target_and_cleans_up_the_temp() {
        let dir = std::env::temp_dir().join("nfarms-store-atomic-fail");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let p = dir.join(FILE);
        std::fs::create_dir_all(&p).unwrap();

        let mut s = Store::load(&p);
        s.set_str("k", "v");
        let err = s.save().expect_err("目标顶不上去时必须报错,不能装作写成功");
        assert!(
            err.contains("替换") && err.contains("原文件没动"),
            "错误要说清是「替换失败、原文件没动」,而不是笼统的写失败:{err}"
        );
        assert!(p.is_dir(), "替换失败后原目标必须原样还在");
        assert!(
            !dir.join(format!("{FILE}.tmp")).exists(),
            "失败的临时文件不能留在盘上:{err}"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 落盘字节是**磁盘上的契约**(用户会直接打开这个文件手改),换写法不能改字节。
    ///
    /// 这条同时钉住两件事:去掉 `self.data.clone()` 不改变输出(旧写法的
    /// `Value::Object(clone)` 序列化出来就是同一个对象);改走 `.tmp + rename`
    /// 之后落盘的仍是同一份 2 空格缩进的 pretty JSON、且结尾不带换行。
    /// 键序是 `serde_json::Map`(默认 BTreeMap)的字典序 —— 钉住它,
    /// 以后谁给 serde_json 打开 `preserve_order`,这里会先红。
    #[test]
    fn saved_bytes_are_the_same_pretty_json_as_before() {
        let p = tmp("format");
        let mut s = Store::load(&p);
        s.set_str("k", "v");
        s.add_bookmark("T", "https://a/", "2026-10-14 22:00:00");
        s.save().unwrap();

        let expected = "{\n  \"bookmarks\": [\n    {\n      \"added\": \"2026-10-14 22:00:00\",\n      \"title\": \"T\",\n      \"url\": \"https://a/\"\n    }\n  ],\n  \"k\": \"v\"\n}";
        assert_eq!(
            std::fs::read_to_string(&p).unwrap(),
            expected,
            "落盘格式变了:用户手改过的文件会和程序写出来的对不上"
        );
    }

    // ----------------------------------------------------- 书签判重口径

    /// 结尾斜杠 / 首尾空白 / scheme 与 host 的大小写都不该各占一条。
    #[test]
    fn trivially_different_url_spellings_are_one_bookmark() {
        let p = tmp("dedup-spelling");
        let mut s = Store::load(&p);
        assert!(s.add_bookmark("NFarms", "https://nfarms.cn/", "t"));
        assert!(
            !s.add_bookmark("again", "  https://NFarms.CN  ", "t"),
            "归一化之后就是同一条,不该再加进去"
        );
        assert_eq!(s.bookmarks().len(), 1);
        assert_eq!(
            s.bookmarks()[0].url, "https://nfarms.cn/",
            "存的必须是用户当初粘的那串,归一化只用于比较"
        );
    }

    /// **路径大小写不参与归一化** —— 这是有意的取舍,不是漏做。
    ///
    /// HTTP 路径大小写敏感,`/Report` 和 `/report` 可能是两个资源;
    /// 整串转小写会让第二条永远加不进来("去重"变成"丢数据")。
    #[test]
    fn paths_that_differ_only_by_case_stay_separate() {
        let p = tmp("dedup-case-path");
        let mut s = Store::load(&p);
        assert!(s.add_bookmark("A", "https://host/Report", "t"));
        assert!(
            s.add_bookmark("B", "https://host/report", "t"),
            "路径大小写不同必须算两条"
        );
        assert_eq!(s.bookmarks().len(), 2);
    }

    /// `remove` 必须和 `add` 用同一套身份,否则会出现"加不进去也删不掉"的条目。
    #[test]
    fn remove_uses_the_same_identity_as_add() {
        let p = tmp("remove-normalized");
        let mut s = Store::load(&p);
        s.add_bookmark("NFarms", "https://nfarms.cn/", "t");
        assert!(
            s.remove_bookmark("https://NFarms.cn"),
            "add 认为这两串是同一条,remove 就必须也认"
        );
        assert!(s.bookmarks().is_empty());
    }

    /// 旧版写下的文件里可能同时存着 `https://a` 和 `https://a/`(归一化后同键)。
    /// 界面上点一次删除是删**一行**,不能把另一条也一起带走。
    #[test]
    fn removing_one_of_two_same_key_bookmarks_takes_only_one() {
        let p = tmp("remove-one-of-same-key");
        std::fs::write(
            &p,
            r#"{"bookmarks":[{"title":"A","url":"https://a","added":"t"},{"title":"B","url":"https://a/","added":"t"}]}"#,
        )
        .unwrap();
        let mut s = Store::load(&p);
        assert_eq!(s.bookmarks().len(), 2);

        assert!(s.remove_bookmark("https://a"), "精确命中,应当删掉 A");
        let left = s.bookmarks();
        assert_eq!(left.len(), 1, "只该删掉点中的那一条");
        assert_eq!(left[0].url, "https://a/", "留下的是 B");
    }

    // ------------------------------------------------- 坏书签条目要交代

    /// 格式不对的条目会被 `bookmarks()` 的 `filter_map` 无声吃掉 —— 必须报出来。
    #[test]
    fn bookmark_entries_the_reader_drops_are_reported_at_load() {
        let p = tmp("bad-bookmark-entry");
        // 第二条的 `url` 被手改成了数字 —— `Bookmark::from_json` 返回 `None`。
        let raw = r#"{"bookmarks":[{"title":"好","url":"https://a/","added":"t"},{"title":"坏","url":42}]}"#;
        std::fs::write(&p, raw).unwrap();

        let s = Store::load(&p);
        let note = s
            .load_note
            .as_deref()
            .expect("被丢掉的条目必须报出来,不能无声消失");
        assert!(
            note.contains("1 条书签格式不对"),
            "提示要说清丢了几条:{note}"
        );
        assert_eq!(s.bookmarks().len(), 1, "能读的照常读出来");
        // load 不动文件:坏条目还在盘上,用户手工修回来就能恢复;
        // 下一次 add/remove 才会把过滤后的列表写回去。
        assert_eq!(std::fs::read_to_string(&p).unwrap(), raw);
    }

    /// `bookmarks` 整个不是数组时一个都读不出来,同样要报。
    #[test]
    fn bookmarks_that_are_not_an_array_are_reported() {
        let p = tmp("bookmarks-not-array");
        std::fs::write(&p, r#"{"bookmarks":{"a":1}}"#).unwrap();
        let s = Store::load(&p);
        let note = s.load_note.as_deref().expect("bookmarks 不是数组也必须报");
        assert!(note.contains("不是数组"), "{note}");
        assert!(s.bookmarks().is_empty());
    }

    /// `added` 缺失时容忍(条目照常读出来),但不编时间戳,只如实说一句。
    #[test]
    fn a_bookmark_without_added_is_kept_but_reported() {
        let p = tmp("undated-bookmark");
        std::fs::write(&p, r#"{"bookmarks":[{"title":"老","url":"https://old/"}]}"#).unwrap();
        let s = Store::load(&p);

        assert_eq!(s.bookmarks().len(), 1, "缺 added 不该让整条书签消失");
        assert_eq!(
            s.bookmarks()[0].added, "",
            "**不**回填时间戳:把「本次启动时间」当「加入时间」比不显示更误导"
        );
        let note = s
            .load_note
            .as_deref()
            .expect("缺 added 要如实说,不能让那一列静悄悄地空着");
        assert!(note.contains("没有加入时间"), "{note}");
    }

    // --------------------------------------------------------------- 端口

    /// 默认值 = 统一迁移后的端口表。五个数全在这里钉住:它们同时被界面、
    /// `observability` 生成的 provision 文件、拼服务参数的函数引用,
    /// 散成字面量迟早会漂成"界面显示 9091、provision 里写 9092"。
    #[test]
    fn default_ports_match_the_unified_migration() {
        let p = tmp("ports-default");
        let s = Store::load(&p); // 文件不存在 = 全新安装
        assert_eq!(s.llama_port(), 9094, "llama.cpp 的旧默认 8080 已迁移");
        assert_eq!(s.sd_port(), 9095, "sd.cpp 的旧默认 1234 已迁移");
        assert_eq!(s.vm_port(), 9091);
        assert_eq!(s.loki_port(), 9092);
        assert_eq!(s.grafana_port(), 9093);
        // 读出来的默认值必须与导出的常量是同一个数 —— 下游引常量时不会错位。
        assert_eq!(s.llama_port(), DEFAULT_LLAMA_PORT);
        assert_eq!(s.sd_port(), DEFAULT_SD_PORT);
        assert_eq!(s.vm_port(), DEFAULT_VM_PORT);
        assert_eq!(s.loki_port(), DEFAULT_LOKI_PORT);
        assert_eq!(s.grafana_port(), DEFAULT_GRAFANA_PORT);
    }

    /// **存过就认存的那个。** 默认值只影响初值 —— 这是端口迁移最容易出的事故:
    /// 老用户盘上写着 8080,新版本一启动就用 9094 把它顶掉,而 DSH 的 provider
    /// baseURL 还指着 8080,症状是 connection refused。
    #[test]
    fn stored_values_win_over_the_new_defaults_and_are_left_alone() {
        let p = tmp("ports-stored");
        let raw = r#"{"llama_port":"8080","sd_port":"1234","vm_port":"8428","loki_port":3100,"grafana_port":"3000"}"#;
        std::fs::write(&p, raw).unwrap();

        let s = Store::load(&p);
        assert_eq!(s.llama_port(), 8080, "老用户存的值必须原样读回,不能被 9094 顶掉");
        assert_eq!(s.sd_port(), 1234, "sd 的旧值同理,不能被 9095 顶掉");
        assert_eq!(s.vm_port(), 8428, "字符串写法要认");
        assert_eq!(s.loki_port(), 3100, "数字写法也要认(用户手改时更自然)");
        assert_eq!(s.grafana_port(), 3000);
        // 只读不写:**读设置不该有副作用**,load 之后文件一个字节都没动。
        assert_eq!(
            std::fs::read_to_string(&p).unwrap(),
            raw,
            "读端口不许把默认值写回磁盘"
        );
    }

    /// 读和写都通:改 → 存 → 重新 load,值还在;没动过的口子保持默认。
    /// 缺了任何一半(只加字段没入口 / 存了读不回),这条都会红。
    #[test]
    fn ports_survive_save_and_load() {
        let p = tmp("ports-roundtrip");
        let mut s = Store::load(&p);
        s.set_llama_port(19191);
        s.set_sd_port(19192);
        s.set_vm_port(19193);
        s.set_loki_port(19194);
        s.set_grafana_port(19195);
        s.save().unwrap();

        let s2 = Store::load(&p);
        assert_eq!(s2.llama_port(), 19191);
        assert_eq!(s2.sd_port(), 19192);
        assert_eq!(s2.vm_port(), 19193);
        assert_eq!(s2.loki_port(), 19194);
        assert_eq!(s2.grafana_port(), 19195);

        // 只改一个:其余四个必须是默认值,不能被顺手写成别的数。
        let p2 = tmp("ports-roundtrip-one");
        let mut s3 = Store::load(&p2);
        s3.set_loki_port(19194);
        s3.save().unwrap();
        let s4 = Store::load(&p2);
        assert_eq!(s4.loki_port(), 19194);
        assert_eq!(s4.llama_port(), DEFAULT_LLAMA_PORT);
        assert_eq!(s4.sd_port(), DEFAULT_SD_PORT);
        assert_eq!(s4.vm_port(), DEFAULT_VM_PORT);
        assert_eq!(s4.grafana_port(), DEFAULT_GRAFANA_PORT);
    }

    /// 写坏的端口不许一路传下去:`llama_server_args` 或数据源 URL 拿到一个越界的
    /// 数才炸,现场离这里十万八千里。不合法就回落默认 ——
    /// 与 `LocalModel::load_ports` 的校验口径一致(非法值丢弃、用默认)。
    #[test]
    fn a_broken_port_value_falls_back_to_the_default() {
        let p = tmp("ports-broken-value");
        std::fs::write(
            &p,
            r#"{"llama_port":"  ","sd_port":"9095x","vm_port":70000,"loki_port":-1,"grafana_port":true}"#,
        )
        .unwrap();
        let s = Store::load(&p);
        assert_eq!(s.llama_port(), DEFAULT_LLAMA_PORT, "空串不算端口");
        assert_eq!(s.sd_port(), DEFAULT_SD_PORT, "带垃圾后缀不算端口");
        assert_eq!(s.vm_port(), DEFAULT_VM_PORT, "超出 u16 不算端口");
        assert_eq!(s.loki_port(), DEFAULT_LOKI_PORT, "负数不算端口");
        assert_eq!(s.grafana_port(), DEFAULT_GRAFANA_PORT, "布尔值不算端口");
    }

    /// 端口和其它键互不干扰:存端口不能把书签顶掉(它们共用一个 `data`)。
    #[test]
    fn writing_ports_keeps_the_other_keys() {
        let p = tmp("ports-keep-keys");
        let mut s = Store::load(&p);
        s.add_bookmark("NFarms", "https://nfarms.cn/", "t");
        s.set_vm_port(19193);
        s.save().unwrap();

        let s2 = Store::load(&p);
        assert_eq!(s2.vm_port(), 19193);
        assert_eq!(s2.bookmarks().len(), 1, "存端口不该动到书签");
    }
}