diff --git a/app/src/index.ts b/app/src/index.ts index 27d32483d..606555706 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -142,6 +142,19 @@ export class App { window.siyuan.storage[data.data.key] = data.data.val; } break; + case "setLocalStorageVals": + Object.keys(data.data.keyVals).forEach((k) => { + window.siyuan.storage[k] = data.data.keyVals[k]; + }); + break; + case "removeLocalStorageVal": + delete window.siyuan.storage[data.data.key]; + break; + case "removeLocalStorageVals": + data.data.keys.forEach((k: string) => { + delete window.siyuan.storage[k]; + }); + break; case "rename": getAllTabs().forEach((tab) => { if (tab.headElement) { diff --git a/app/src/layout/tabUtil.ts b/app/src/layout/tabUtil.ts index 38aa93586..812d73e77 100644 --- a/app/src/layout/tabUtil.ts +++ b/app/src/layout/tabUtil.ts @@ -357,22 +357,26 @@ export const copyTab = (app: App, tab: Tab) => { }); }; -const getRootID = (item: Tab) => { +const pushRootID = (rootIDs: string[], item: Tab) => { + let id; if (item.model instanceof Editor) { - return item.model.editor.protyle.block.rootID; + id = item.model.editor.protyle.block.rootID; } else if (!item.model) { const initTab = item.headElement.getAttribute("data-initdata"); if (initTab) { try { const initTabData = JSON.parse(initTab); if (initTabData && initTabData.instance === "Editor" && initTabData.rootId) { - return initTabData.rootId; + id = initTabData.rootId; } } catch (e) { console.warn("Failed to parse tab init data:", e); } } } + if (id) { + rootIDs.push(id); + } }; export const closeTabByType = (tab: Tab, type: "closeOthers" | "closeAll" | "other", tabs?: Tab[]) => { @@ -381,7 +385,7 @@ export const closeTabByType = (tab: Tab, type: "closeOthers" | "closeAll" | "oth for (let index = 0; index < tab.parent.children.length; index++) { const item = tab.parent.children[index]; if (item.id !== tab.id && !item.headElement.classList.contains("item--pin")) { - rootIDs.push(getRootID(item)); + pushRootID(rootIDs, item); item.parent.removeTab(item.id, true, false); index--; } @@ -390,7 +394,7 @@ export const closeTabByType = (tab: Tab, type: "closeOthers" | "closeAll" | "oth for (let index = 0; index < tab.parent.children.length; index++) { const item = tab.parent.children[index]; if (!item.headElement.classList.contains("item--pin")) { - rootIDs.push(getRootID(item)); + pushRootID(rootIDs, item); item.parent.removeTab(item.id, true); index--; } diff --git a/app/src/mobile/util/onMessage.ts b/app/src/mobile/util/onMessage.ts index 060b0a76b..5f3939b7a 100644 --- a/app/src/mobile/util/onMessage.ts +++ b/app/src/mobile/util/onMessage.ts @@ -77,6 +77,22 @@ export const onMessage = (app: App, data: IWebSocketData) => { case "readonly": window.siyuan.config.editor.readOnly = data.data; break; + case "setLocalStorageVal": + window.siyuan.storage[data.data.key] = data.data.val; + break; + case "setLocalStorageVals": + Object.keys(data.data.keyVals).forEach((k) => { + window.siyuan.storage[k] = data.data.keyVals[k]; + }); + break; + case "removeLocalStorageVal": + delete window.siyuan.storage[data.data.key]; + break; + case "removeLocalStorageVals": + data.data.keys.forEach((k: string) => { + delete window.siyuan.storage[k]; + }); + break; case"progress": progressLoading(data); break; diff --git a/app/src/plugin/platformUtils.ts b/app/src/plugin/platformUtils.ts index c756604ea..f0d46ba5b 100644 --- a/app/src/plugin/platformUtils.ts +++ b/app/src/plugin/platformUtils.ts @@ -20,6 +20,10 @@ export const updateHotkeyTip = compatibility.updateHotkeyTip; export const getLocalStorage = compatibility.getLocalStorage; export const setStorageVal = compatibility.setStorageVal; +export const getStorageVal = (key: string): any => { + return window.siyuan.storage?.[key] ?? null; // 不存在时与接口响应一致使用 null +}; + /** * @param {string} [options.timeoutType="defalut"] 仅在 Windows 和 Linux 有效,"default" 表示使用默认的超时机制,"never" 表示通知将一直显示,直到用户手动关闭它。 * @returns 通知 id diff --git a/app/src/window/index.ts b/app/src/window/index.ts index 166df90cd..e58e724c9 100644 --- a/app/src/window/index.ts +++ b/app/src/window/index.ts @@ -101,6 +101,19 @@ class App { window.siyuan.storage[data.data.key] = data.data.val; } break; + case "setLocalStorageVals": + Object.keys(data.data.keyVals).forEach((k) => { + window.siyuan.storage[k] = data.data.keyVals[k]; + }); + break; + case "removeLocalStorageVal": + delete window.siyuan.storage[data.data.key]; + break; + case "removeLocalStorageVals": + data.data.keys.forEach((k: string) => { + delete window.siyuan.storage[k]; + }); + break; case "rename": getAllTabs().forEach((tab) => { if (tab.headElement) { diff --git a/kernel/api/router.go b/kernel/api/router.go index bbcf96687..552d02b13 100644 --- a/kernel/api/router.go +++ b/kernel/api/router.go @@ -82,17 +82,21 @@ func ServeAPI(ginServer *gin.Engine) { ginServer.Handle("POST", "/api/storage/setLocalStorage", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, deprecated) // TODO 请使用 /api/storage/setLocalStorageVal,该端点将于 2026 年 12 月 1 日后删除 ginServer.Handle("POST", "/api/storage/getLocalStorage", model.CheckAuth, getLocalStorage) + ginServer.Handle("POST", "/api/storage/getLocalStorageVal", model.CheckAuth, getLocalStorageVal) + ginServer.Handle("POST", "/api/storage/getLocalStorageVals", model.CheckAuth, getLocalStorageVals) + ginServer.Handle("POST", "/api/storage/setLocalStorage", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, setLocalStorage, deprecated) // TODO 请使用 /api/storage/setLocalStorageVal,该端点计划于 2026 年 6 月 30 日后删除 https://github.com/siyuan-note/siyuan/issues/16664#issuecomment-3694774305 ginServer.Handle("POST", "/api/storage/setLocalStorageVal", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, setLocalStorageVal) + ginServer.Handle("POST", "/api/storage/setLocalStorageVals", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, setLocalStorageVals) + ginServer.Handle("POST", "/api/storage/removeLocalStorageVal", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, removeLocalStorageVal) ginServer.Handle("POST", "/api/storage/removeLocalStorageVals", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, removeLocalStorageVals) - ginServer.Handle("POST", "/api/storage/setCriterion", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, setCriterion) ginServer.Handle("POST", "/api/storage/getCriteria", model.CheckAuth, getCriteria) + ginServer.Handle("POST", "/api/storage/setCriterion", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, setCriterion) ginServer.Handle("POST", "/api/storage/removeCriterion", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, removeCriterion) ginServer.Handle("POST", "/api/storage/getRecentDocs", model.CheckAuth, getRecentDocs) + ginServer.Handle("POST", "/api/storage/updateRecentDocOpenTime", model.CheckAuth, updateRecentDocOpenTime) ginServer.Handle("POST", "/api/storage/updateRecentDocViewTime", model.CheckAuth, updateRecentDocViewTime) ginServer.Handle("POST", "/api/storage/updateRecentDocCloseTime", model.CheckAuth, updateRecentDocCloseTime) ginServer.Handle("POST", "/api/storage/batchUpdateRecentDocCloseTime", model.CheckAuth, batchUpdateRecentDocCloseTime) - ginServer.Handle("POST", "/api/storage/updateRecentDocOpenTime", model.CheckAuth, updateRecentDocOpenTime) - ginServer.Handle("POST", "/api/storage/getOutlineStorage", model.CheckAuth, getOutlineStorage) ginServer.Handle("POST", "/api/storage/setOutlineStorage", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, setOutlineStorage) ginServer.Handle("POST", "/api/storage/removeOutlineStorage", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, removeOutlineStorage) diff --git a/kernel/api/storage.go b/kernel/api/storage.go index 66680eccc..8a4a6388c 100644 --- a/kernel/api/storage.go +++ b/kernel/api/storage.go @@ -60,7 +60,11 @@ func removeCriterion(c *gin.Context) { return } - name := arg["name"].(string) + var name string + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("name", &name, true, true)) { + return + } + err := model.RemoveCriterion(name) if err != nil { ret.Code = -1 @@ -78,7 +82,12 @@ func setCriterion(c *gin.Context) { return } - param, err := gulu.JSON.MarshalJSON(arg["criterion"]) + var criterionRaw any + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("criterion", &criterionRaw, true, false)) { + return + } + + param, err := gulu.JSON.MarshalJSON(criterionRaw) if err != nil { ret.Code = -1 ret.Msg = err.Error() @@ -117,10 +126,29 @@ func removeLocalStorageVals(c *gin.Context) { return } + var keysArg []any + var app string + if !util.ParseJsonArgs(arg, ret, + util.BindJsonArg("keys", &keysArg, true, true), + util.BindJsonArg("app", &app, false, false), + ) { + return + } + var keys []string - keysArg := arg["keys"].([]any) for _, key := range keysArg { - keys = append(keys, key.(string)) + ks, elemOk := key.(string) + if !elemOk { + ret.Code = -1 + ret.Msg = "Field [keys]: each element should be of type [String]" + return + } + if ks == "" { + ret.Code = -1 + ret.Msg = "Field [keys]: each element must not be empty" + return + } + keys = append(keys, ks) } err := model.RemoveLocalStorageVals(keys) @@ -130,13 +158,43 @@ func removeLocalStorageVals(c *gin.Context) { return } - app := arg["app"].(string) evt := util.NewCmdResult("removeLocalStorageVals", 0, util.PushModeBroadcastMainExcludeSelfApp) evt.AppId = app evt.Data = map[string]any{"keys": keys} util.PushEvent(evt) } +func removeLocalStorageVal(c *gin.Context) { + ret := gulu.Ret.NewResult() + defer c.JSON(http.StatusOK, ret) + + arg, ok := util.JsonArg(c, ret) + if !ok { + return + } + + var key string + var app string + if !util.ParseJsonArgs(arg, ret, + util.BindJsonArg("key", &key, true, true), + util.BindJsonArg("app", &app, false, false), + ) { + return + } + + err := model.RemoveLocalStorageVals([]string{key}) + if err != nil { + ret.Code = -1 + ret.Msg = err.Error() + return + } + + evt := util.NewCmdResult("removeLocalStorageVal", 0, util.PushModeBroadcastMainExcludeSelfApp) + evt.AppId = app + evt.Data = map[string]any{"key": key} + util.PushEvent(evt) +} + func setLocalStorageVal(c *gin.Context) { ret := gulu.Ret.NewResult() defer c.JSON(http.StatusOK, ret) @@ -146,22 +204,60 @@ func setLocalStorageVal(c *gin.Context) { return } - key := arg["key"].(string) - val := arg["val"].(any) - err := model.SetLocalStorageVal(key, val) + var key string + var app string + if !util.ParseJsonArgs(arg, ret, + util.BindJsonArg("key", &key, true, true), + util.BindJsonArg("app", &app, false, false), + ) { + return + } + val := arg["val"] + + setKeyVals, err := model.SetLocalStorageVals(map[string]any{key: val}) if err != nil { ret.Code = -1 ret.Msg = err.Error() return } - app := arg["app"].(string) evt := util.NewCmdResult("setLocalStorageVal", 0, util.PushModeBroadcastMainExcludeSelfApp) evt.AppId = app - evt.Data = map[string]any{"key": key, "val": val} + evt.Data = map[string]any{"key": key, "val": setKeyVals[key]} util.PushEvent(evt) } +func setLocalStorageVals(c *gin.Context) { + ret := gulu.Ret.NewResult() + defer c.JSON(http.StatusOK, ret) + + arg, ok := util.JsonArg(c, ret) + if !ok { + return + } + + var keyVals map[string]any + var app string + if !util.ParseJsonArgs(arg, ret, + util.BindJsonArg("keyVals", &keyVals, true, true), + util.BindJsonArg("app", &app, false, false), + ) { + return + } + + setKeyVals, err := model.SetLocalStorageVals(keyVals) + if err != nil { + ret.Code = -1 + ret.Msg = err.Error() + return + } + + evtSet := util.NewCmdResult("setLocalStorageVals", 0, util.PushModeBroadcastMainExcludeSelfApp) + evtSet.AppId = app + evtSet.Data = map[string]any{"keyVals": setKeyVals} + util.PushEvent(evtSet) +} + func setLocalStorage(c *gin.Context) { ret := gulu.Ret.NewResult() defer c.JSON(http.StatusOK, ret) @@ -171,7 +267,11 @@ func setLocalStorage(c *gin.Context) { return } - val := arg["val"].(any) + var val map[string]any + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("val", &val, true, false)) { + return + } + err := model.SetLocalStorage(val) if err != nil { ret.Code = -1 @@ -192,6 +292,70 @@ func getLocalStorage(c *gin.Context) { ret.Data = data } +func getLocalStorageVal(c *gin.Context) { + ret := gulu.Ret.NewResult() + defer c.JSON(http.StatusOK, ret) + + arg, ok := util.JsonArg(c, ret) + if !ok { + return + } + + var key string + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("key", &key, true, true)) { + return + } + + data := model.GetLocalStorage() + if model.IsReadOnlyRoleContext(c) { + publishAccess := model.GetPublishAccess() + data = model.FilterLocalStorageByPublishAccess(publishAccess, data) + } + ret.Data = data[key] +} + +func getLocalStorageVals(c *gin.Context) { + ret := gulu.Ret.NewResult() + defer c.JSON(http.StatusOK, ret) + + arg, ok := util.JsonArg(c, ret) + if !ok { + return + } + + var keysArg []any + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("keys", &keysArg, true, true)) { + return + } + + var keys []string + for _, key := range keysArg { + ks, elemOk := key.(string) + if !elemOk { + ret.Code = -1 + ret.Msg = "Field [keys]: each element should be of type [String]" + return + } + if ks == "" { + ret.Code = -1 + ret.Msg = "Field [keys]: each element must not be empty" + return + } + keys = append(keys, ks) + } + + data := model.GetLocalStorage() + if model.IsReadOnlyRoleContext(c) { + publishAccess := model.GetPublishAccess() + data = model.FilterLocalStorageByPublishAccess(publishAccess, data) + } + out := map[string]any{} + for _, k := range keys { + out[k] = data[k] + } + ret.Data = out +} + func getOutlineStorage(c *gin.Context) { ret := gulu.Ret.NewResult() defer c.JSON(http.StatusOK, ret) @@ -201,7 +365,11 @@ func getOutlineStorage(c *gin.Context) { return } - docID := arg["docID"].(string) + var docID string + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("docID", &docID, true, true)) { + return + } + data, err := model.GetOutlineStorage(docID) if err != nil { ret.Code = -1 @@ -220,8 +388,15 @@ func setOutlineStorage(c *gin.Context) { return } - docID := arg["docID"].(string) - val := arg["val"].(any) + var docID string + var val map[string]any + if !util.ParseJsonArgs(arg, ret, + util.BindJsonArg("docID", &docID, true, true), + util.BindJsonArg("val", &val, true, false), + ) { + return + } + err := model.SetOutlineStorage(docID, val) if err != nil { ret.Code = -1 @@ -239,7 +414,11 @@ func removeOutlineStorage(c *gin.Context) { return } - docID := arg["docID"].(string) + var docID string + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("docID", &docID, true, true)) { + return + } + err := model.RemoveOutlineStorage(docID) if err != nil { ret.Code = -1 @@ -257,11 +436,11 @@ func updateRecentDocViewTime(c *gin.Context) { return } - if nil == arg["rootID"] { + var rootID string + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("rootID", &rootID, true, true)) { return } - rootID := arg["rootID"].(string) err := model.UpdateRecentDocViewTime(rootID) if err != nil { ret.Code = -1 @@ -279,11 +458,11 @@ func updateRecentDocOpenTime(c *gin.Context) { return } - if nil == arg["rootID"] { + var rootID string + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("rootID", &rootID, true, true)) { return } - rootID := arg["rootID"].(string) err := model.UpdateRecentDocOpenTime(rootID) if err != nil { ret.Code = -1 @@ -301,8 +480,8 @@ func updateRecentDocCloseTime(c *gin.Context) { return } - rootID, ok := arg["rootID"].(string) - if !ok || rootID == "" { + var rootID string + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("rootID", &rootID, true, true)) { return } @@ -323,10 +502,25 @@ func batchUpdateRecentDocCloseTime(c *gin.Context) { return } - rootIDsArg := arg["rootIDs"].([]any) + var rootIDsArg []any + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("rootIDs", &rootIDsArg, true, true)) { + return + } + var rootIDs []string for _, id := range rootIDsArg { - rootIDs = append(rootIDs, id.(string)) + str, elemOk := id.(string) + if !elemOk { + ret.Code = -1 + ret.Msg = "Field [rootIDs]: each element should be of type [String]" + return + } + if str == "" { + ret.Code = -1 + ret.Msg = "Field [rootIDs]: each element must not be empty" + return + } + rootIDs = append(rootIDs, str) } err := model.BatchUpdateRecentDocCloseTime(rootIDs) diff --git a/kernel/model/storage.go b/kernel/model/storage.go index 5daa9f742..897025c7c 100644 --- a/kernel/model/storage.go +++ b/kernel/model/storage.go @@ -18,6 +18,7 @@ package model import ( "errors" + "fmt" "os" "path" "path/filepath" @@ -646,16 +647,25 @@ func RemoveLocalStorageVals(keys []string) (err error) { return setLocalStorage(localStorage) } -func SetLocalStorageVal(key string, val any) (err error) { +func SetLocalStorageVals(keyVals map[string]any) (setKeyVals map[string]any, err error) { localStorageLock.Lock() defer localStorageLock.Unlock() + setKeyVals = make(map[string]any, len(keyVals)) localStorage := getLocalStorage() - localStorage[key] = val - return setLocalStorage(localStorage) + for k, v := range keyVals { + if v == nil { + err = fmt.Errorf("local storage value for key [%s] must not be empty", k) + return + } + localStorage[k] = v + setKeyVals[k] = v + } + err = setLocalStorage(localStorage) + return } -func SetLocalStorage(val any) (err error) { +func SetLocalStorage(val map[string]any) (err error) { localStorageLock.Lock() defer localStorageLock.Unlock() return setLocalStorage(val) @@ -667,7 +677,7 @@ func GetLocalStorage() (ret map[string]any) { return getLocalStorage() } -func setLocalStorage(val any) (err error) { +func setLocalStorage(val map[string]any) (err error) { dirPath := filepath.Join(util.DataDir, "storage") if err = os.MkdirAll(dirPath, 0755); err != nil { logging.LogErrorf("create storage [local] dir failed: %s", err) @@ -731,17 +741,13 @@ func GetOutlineStorage(docID string) (ret map[string]any, err error) { return } -func SetOutlineStorage(docID string, val any) (err error) { +func SetOutlineStorage(docID string, val map[string]any) (err error) { outlineStorageLock.Lock() defer outlineStorageLock.Unlock() outlineDoc := &OutlineDoc{ DocID: docID, - Data: make(map[string]any), - } - - if valMap, ok := val.(map[string]any); ok { - outlineDoc.Data = valMap + Data: val, } outlineDocs, err := getOutlineDocs() diff --git a/kernel/util/net.go b/kernel/util/net.go index b7e054a34..4af21fc48 100644 --- a/kernel/util/net.go +++ b/kernel/util/net.go @@ -222,7 +222,7 @@ func JsonArg(c *gin.Context, result *gulu.Result) (arg map[string]any, ok bool) // ParseJsonArg 使用泛型从 JSON 参数中提取指定键的值。 // - 如果 required 为 true 但参数缺失,则会在 ret.Msg 中说明需要传入的键 -// - 如果 rejectEmpty 为 true 但参数值为空,则会在 ret.Msg 中说明该键必须不为空 +// - 如果 rejectEmpty 为 true 但参数值为空,则会在 ret.Msg 中说明该键必须不为空(字符串去空白后、空数组、无任何键的对象) // - 如果参数存在但类型不匹配,则会在 ret.Msg 中说明该键期望的类型 // - 返回值 ok 为 false 时,表示提取失败、类型不匹配或不满足非空约束 func ParseJsonArg[T any](key string, arg map[string]any, ret *gulu.Result, required, rejectEmpty bool) (value T, ok bool) { @@ -274,6 +274,8 @@ func ParseJsonArg[T any](key string, arg map[string]any, ret *gulu.Result, requi } case []any: bad = len(x) == 0 + case map[string]any: + bad = len(x) == 0 } if bad { ret.Code = -1