{
Settings::default()
});
- Ok(SettingsDocument { version, settings })
+ Ok(SettingsDocument {
+ version,
+ settings: with_builtin_engines(settings),
+ })
+}
+
+/// 老库里没有后来新增的内置引擎时补一份默认配置;
+/// 用户已经改过的名称 / 地址 / 启停保持不动(按 id 判断),新增的插在内置引擎那一组里。
+fn with_builtin_engines(mut settings: Settings) -> Settings {
+ let builtin_ids: std::collections::HashSet<&str> =
+ BUILTIN_ENGINE_IDS.iter().copied().collect();
+
+ for engine in SearchSettings::default().engines {
+ if settings.search.engines.iter().any(|item| item.id == engine.id) {
+ continue;
+ }
+ let insert_at = settings
+ .search
+ .engines
+ .iter()
+ .rposition(|item| builtin_ids.contains(item.id.as_str()))
+ .map(|position| position + 1)
+ .unwrap_or(0);
+ settings.search.engines.insert(insert_at, engine);
+ }
+ settings
}
/// 保存设置;`expected_version` 与库里不一致时返回冲突。
@@ -685,6 +727,47 @@ mod tests {
assert!(save(&mut conn, bad_icon, 0).is_err(), "非法图标地址应被拒绝");
}
+ #[test]
+ fn builtin_engines_cannot_be_removed() {
+ let mut conn = db();
+ let mut missing = Settings::default();
+ missing.search.engines.retain(|engine| engine.id != "duckduckgo");
+ assert!(save(&mut conn, missing, 0).is_err(), "缺内置引擎应被拒绝");
+
+ // 改名 / 停用不算删除,允许
+ let mut renamed = Settings::default();
+ renamed.search.engines[0].name = "改过的名字".into();
+ renamed.search.engines[0].enabled = false;
+ assert!(save(&mut conn, renamed, 0).is_ok());
+ }
+
+ #[test]
+ fn load_merges_new_builtin_engines() {
+ let conn = db();
+ let mut old = Settings::default();
+ old.search.engines.retain(|engine| engine.id != "duckduckgo");
+ old.search.engines[0].name = "我改过的 Google".into();
+ let raw = serde_json::to_string(&old).expect("应能序列化");
+ conn.execute(
+ "INSERT INTO setting (key, value, version, updated_at) VALUES (?1, ?2, 5, 0)",
+ params![SETTINGS_KEY, raw],
+ )
+ .expect("应能写入旧设置");
+
+ let loaded = load(&conn).expect("应能读取");
+ assert_eq!(loaded.version, 5);
+ assert_eq!(loaded.settings.search.engines[0].name, "我改过的 Google");
+ assert!(
+ loaded
+ .settings
+ .search
+ .engines
+ .iter()
+ .any(|engine| engine.id == "duckduckgo"),
+ "老设置里缺的 DuckDuckGo 应被补上"
+ );
+ }
+
#[test]
fn invalid_settings_are_rejected() {
let mut conn = db();
diff --git a/stash-web/src/components/admin/AdminNext.tsx b/stash-web/src/components/admin/AdminNext.tsx
index 29b2353..13084b2 100644
--- a/stash-web/src/components/admin/AdminNext.tsx
+++ b/stash-web/src/components/admin/AdminNext.tsx
@@ -37,6 +37,7 @@ import { LoginDialog } from '../auth/LoginDialog'
import { GroupMark as GroupGlyph } from '../common/GroupMark'
import { isDarkScheme, readThemeOverride, writeThemeOverride } from '../../theme'
import {
+ BUILTIN_SEARCH_ENGINE_IDS,
DEFAULT_SEARCH_ENGINES,
fetchSettings,
saveSettings,
@@ -2110,6 +2111,8 @@ function SearchPanel({
}
const removeEngine = (id: string) => {
+ // 内置引擎只能停用,不能删
+ if (BUILTIN_SEARCH_ENGINE_IDS.some((builtin) => builtin === id)) return
const engines = value.engines.filter((engine) => engine.id !== id)
onChange({
...value,
@@ -2156,7 +2159,9 @@ function SearchPanel({
label={`启用 ${engine.name || engine.id}`}
onChange={(enabled) => updateEngine(engine.id, { enabled })}
/>
- removeEngine(engine.id)}>删除
+ {!BUILTIN_SEARCH_ENGINE_IDS.some((builtin) => builtin === engine.id) && (
+ removeEngine(engine.id)}>删除
+ )}
diff --git a/stash-web/src/settings.ts b/stash-web/src/settings.ts
index f38544a..937619c 100644
--- a/stash-web/src/settings.ts
+++ b/stash-web/src/settings.ts
@@ -132,8 +132,25 @@ export const DEFAULT_SEARCH_ENGINES: SearchEngineSettings[] = [
icon: '',
enabled: true,
},
+ {
+ id: 'duckduckgo',
+ name: 'DuckDuckGo',
+ url: 'https://duckduckgo.com/?q={query}',
+ icon: '',
+ enabled: true,
+ },
]
+/** 内置引擎:有品牌图标、后台不能删除(只能改名 / 改地址 / 停用) */
+export const BUILTIN_SEARCH_ENGINE_IDS = [
+ 'google',
+ 'bing',
+ 'baidu',
+ 'sogou',
+ '360',
+ 'duckduckgo',
+] as const
+
export type SiteSettings = {
header: {
enabled: boolean