Improve LocalStorage related APIs (#17482)

* 🎨 Improve LocalStorage related APIs

* 🎨 Improve LocalStorage related APIs

---------

Co-authored-by: D <845765@qq.com>
This commit is contained in:
Jeffrey Chen
2026-05-08 22:39:24 +08:00
committed by GitHub
parent 33f1f8d5f5
commit 3aaa96a0fe
9 changed files with 299 additions and 43 deletions

View File

@@ -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) {

View File

@@ -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--;
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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