siyuan/kernel/model/storage.go
Achuan-2 7545c2517f
Improve the outline panel (#15814)
*  实现大纲持久化
- 添加折叠状态变化的实时保存功能
- 在文档更新时恢复折叠状态
- 保存拖拽前的折叠状态并在拖拽后恢复
- 更新本地存储结构以支持折叠状态

* 🎨 clean code

*  在`data/storage`文件夹下创建`outline/${docID}.json`文件,存储标题outline展开信息

*  alt click折叠/展开统计标题

* 添加层级控制功能
- 新增层级控制滑条,允许用户展开指定层级

* 🌐 添加多语言支持的展开层级功能

*  f添加层级重置显示功能
- 新增 resetLevelDisplay 方法以重置层级显示状态
- 更新层级控制的初始化逻辑,默认不显示层级
- 在文档切换时重置层级显示状态

* 优化层级控制功能
- 添加用户主动层级控制标记
- 修改层级显示重置逻辑,仅在非用户操作时重置
- 更新层级控制滑条的点击事件处理

* ♻️ 重构大纲存储逻辑
- 合并大纲存储为单一文件 outline.json

* ♻️ outline.json 单行存储

* ♻️ outline.json参考recent-doc.json,只存储前1000个文档信息,每次新增信息会把数据放在最前面

* ♻️单行存储json

* ♻️ 增加outline.json存储限制至2000个

*  新增`保持当前标题展开`按钮,`保持全部展开`改为`全部展开`按钮

*  保持当前标题展开优化
- 超过两级折叠,也能都展开
- 如果父节点折叠,展开时自动折叠兄弟节点,只展开当前节点路径,如果父节点是展开状态,则不影响兄弟节点折叠状态

* 🔥 移除层级文本

*  右键点击toggle时展开所有子标题

*  右键click点击折叠图标,会折叠/展开所有子标题

*  大纲支持筛选功能

*  feat(大纲): 优化筛选功能以显示所有子标题
- 添加 showAllDescendants 函数以显示所有子标题
- 修改 processUL 函数以在父标题命中时显示所有子标题
- 确保未命中的子标题隐藏

*  优化大纲筛选

- 如果标题命中筛选,这个标题的所有父标题展开,以显示出这个标题位置
- 如果父标题命中筛选,子项都没有命中筛选,则折叠全部子项(依然可以展开显示)
- 如果父标题命中筛选,子项也有命中筛选,则展开命中的子项,其他无关子项隐藏

* 💄 展开层级改为按钮,原先的圆点样式碍眼

* 💄 展开层级改为按钮,原先的圆点样式碍眼

* 💄 style(菜单): 优化展开层级菜单的样式和位置
- 添加图标以增强可视化
- 调整菜单弹出位置以改善用户体验

* ♻️ refactor(大纲): 优化标题级别获取逻辑
- 调整展开到指定层级的逻辑,使用标题级别进行判断

* 🎨

*  feat(大纲): 添加右键菜单功能
- 实现右键点击标题时显示上下文菜单
- 增加标题升级、降级、插入、删除等操作
- 基于标题级别展开/折叠同级标题

*  feat(大纲): 添加子标题功能
- 在右键菜单中添加“添加子标题”选项
- 实现子标题的添加逻辑,支持最大级别为H6
- 使用当前标题作为父标题,插入新子标题

*  feat(大纲): "添加子标题"确保父标题展开状态

*  feat(大纲): 使用expandIds方式保存父标题展开状态
- 确保父标题保持展开状态
- 保存展开状态到持久化存储
- 移除冗余的状态保存逻辑

*  feat(大纲): 调整右键菜单顺序,将“全部折叠”功能移至“全部展开”之后

* 🌐 i18n optimization
2025-10-15 10:01:47 +08:00

530 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SiYuan - Refactor your thinking
// Copyright (c) 2020-present, b3log.org
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package model
import (
"errors"
"os"
"path"
"path/filepath"
"sync"
"github.com/88250/gulu"
"github.com/88250/lute/parse"
"github.com/siyuan-note/filelock"
"github.com/siyuan-note/logging"
"github.com/siyuan-note/siyuan/kernel/treenode"
"github.com/siyuan-note/siyuan/kernel/util"
)
type RecentDoc struct {
RootID string `json:"rootID"`
Icon string `json:"icon"`
Title string `json:"title"`
}
type OutlineDoc struct {
DocID string `json:"docID"`
Data map[string]interface{} `json:"data"`
}
var recentDocLock = sync.Mutex{}
func RemoveRecentDoc(ids []string) {
recentDocLock.Lock()
defer recentDocLock.Unlock()
recentDocs, err := getRecentDocs()
if err != nil {
return
}
ids = gulu.Str.RemoveDuplicatedElem(ids)
for i, doc := range recentDocs {
if gulu.Str.Contains(doc.RootID, ids) {
recentDocs = append(recentDocs[:i], recentDocs[i+1:]...)
break
}
}
err = setRecentDocs(recentDocs)
if err != nil {
return
}
return
}
func setRecentDocByTree(tree *parse.Tree) {
recentDoc := &RecentDoc{
RootID: tree.Root.ID,
Icon: tree.Root.IALAttr("icon"),
Title: tree.Root.IALAttr("title"),
}
recentDocLock.Lock()
defer recentDocLock.Unlock()
recentDocs, err := getRecentDocs()
if err != nil {
return
}
for i, c := range recentDocs {
if c.RootID == recentDoc.RootID {
recentDocs = append(recentDocs[:i], recentDocs[i+1:]...)
break
}
}
recentDocs = append([]*RecentDoc{recentDoc}, recentDocs...)
if 32 < len(recentDocs) {
recentDocs = recentDocs[:32]
}
err = setRecentDocs(recentDocs)
return
}
func GetRecentDocs() (ret []*RecentDoc, err error) {
recentDocLock.Lock()
defer recentDocLock.Unlock()
return getRecentDocs()
}
func setRecentDocs(recentDocs []*RecentDoc) (err error) {
dirPath := filepath.Join(util.DataDir, "storage")
if err = os.MkdirAll(dirPath, 0755); err != nil {
logging.LogErrorf("create storage [recent-doc] dir failed: %s", err)
return
}
data, err := gulu.JSON.MarshalIndentJSON(recentDocs, "", " ")
if err != nil {
logging.LogErrorf("marshal storage [recent-doc] failed: %s", err)
return
}
lsPath := filepath.Join(dirPath, "recent-doc.json")
err = filelock.WriteFile(lsPath, data)
if err != nil {
logging.LogErrorf("write storage [recent-doc] failed: %s", err)
return
}
return
}
func getRecentDocs() (ret []*RecentDoc, err error) {
tmp := []*RecentDoc{}
dataPath := filepath.Join(util.DataDir, "storage/recent-doc.json")
if !filelock.IsExist(dataPath) {
return
}
data, err := filelock.ReadFile(dataPath)
if err != nil {
logging.LogErrorf("read storage [recent-doc] failed: %s", err)
return
}
if err = gulu.JSON.UnmarshalJSON(data, &tmp); err != nil {
logging.LogErrorf("unmarshal storage [recent-doc] failed: %s", err)
return
}
var rootIDs []string
for _, doc := range tmp {
rootIDs = append(rootIDs, doc.RootID)
}
bts := treenode.GetBlockTrees(rootIDs)
var notExists []string
for _, doc := range tmp {
if bt := bts[doc.RootID]; nil != bt {
doc.Title = path.Base(bt.HPath) // Recent docs not updated after renaming https://github.com/siyuan-note/siyuan/issues/7827
ret = append(ret, doc)
} else {
notExists = append(notExists, doc.RootID)
}
}
if 0 < len(notExists) {
setRecentDocs(ret)
}
return
}
type Criterion struct {
Name string `json:"name"`
Sort int `json:"sort"` // 0按块类型默认1按创建时间升序2按创建时间降序3按更新时间升序4按更新时间降序5按内容顺序仅在按文档分组时
Group int `json:"group"` // 0不分组1按文档分组
HasReplace bool `json:"hasReplace"` // 是否有替换
Method int `json:"method"` // 0文本1查询语法2SQL3正则表达式
HPath string `json:"hPath"`
IDPath []string `json:"idPath"`
K string `json:"k"` // 搜索关键字
R string `json:"r"` // 替换关键字
Types *CriterionTypes `json:"types"` // 类型过滤选项
ReplaceTypes *CriterionReplaceTypes `json:"replaceTypes"` // 替换类型过滤选项
}
type CriterionTypes struct {
MathBlock bool `json:"mathBlock"`
Table bool `json:"table"`
Blockquote bool `json:"blockquote"`
SuperBlock bool `json:"superBlock"`
Paragraph bool `json:"paragraph"`
Document bool `json:"document"`
Heading bool `json:"heading"`
List bool `json:"list"`
ListItem bool `json:"listItem"`
CodeBlock bool `json:"codeBlock"`
HtmlBlock bool `json:"htmlBlock"`
EmbedBlock bool `json:"embedBlock"`
DatabaseBlock bool `json:"databaseBlock"`
AudioBlock bool `json:"audioBlock"`
VideoBlock bool `json:"videoBlock"`
IFrameBlock bool `json:"iframeBlock"`
WidgetBlock bool `json:"widgetBlock"`
}
type CriterionReplaceTypes struct {
Text bool `json:"text"`
ImgText bool `json:"imgText"`
ImgTitle bool `json:"imgTitle"`
ImgSrc bool `json:"imgSrc"`
AText bool `json:"aText"`
ATitle bool `json:"aTitle"`
AHref bool `json:"aHref"`
Code bool `json:"code"`
Em bool `json:"em"`
Strong bool `json:"strong"`
InlineMath bool `json:"inlineMath"`
InlineMemo bool `json:"inlineMemo"`
BlockRef bool `json:"blockRef"`
FileAnnotationRef bool `json:"fileAnnotationRef"`
Kbd bool `json:"kbd"`
Mark bool `json:"mark"`
S bool `json:"s"`
Sub bool `json:"sub"`
Sup bool `json:"sup"`
Tag bool `json:"tag"`
U bool `json:"u"`
DocTitle bool `json:"docTitle"`
CodeBlock bool `json:"codeBlock"`
MathBlock bool `json:"mathBlock"`
HtmlBlock bool `json:"htmlBlock"`
}
var criteriaLock = sync.Mutex{}
func RemoveCriterion(name string) (err error) {
criteriaLock.Lock()
defer criteriaLock.Unlock()
criteria, err := getCriteria()
if err != nil {
return
}
for i, c := range criteria {
if c.Name == name {
criteria = append(criteria[:i], criteria[i+1:]...)
break
}
}
err = setCriteria(criteria)
return
}
func SetCriterion(criterion *Criterion) (err error) {
if "" == criterion.Name {
return errors.New(Conf.Language(142))
}
criteriaLock.Lock()
defer criteriaLock.Unlock()
criteria, err := getCriteria()
if err != nil {
return
}
update := false
for i, c := range criteria {
if c.Name == criterion.Name {
criteria[i] = criterion
update = true
break
}
}
if !update {
criteria = append(criteria, criterion)
}
err = setCriteria(criteria)
return
}
func GetCriteria() (ret []*Criterion) {
criteriaLock.Lock()
defer criteriaLock.Unlock()
ret, _ = getCriteria()
return
}
func setCriteria(criteria []*Criterion) (err error) {
dirPath := filepath.Join(util.DataDir, "storage")
if err = os.MkdirAll(dirPath, 0755); err != nil {
logging.LogErrorf("create storage [criteria] dir failed: %s", err)
return
}
data, err := gulu.JSON.MarshalIndentJSON(criteria, "", " ")
if err != nil {
logging.LogErrorf("marshal storage [criteria] failed: %s", err)
return
}
lsPath := filepath.Join(dirPath, "criteria.json")
err = filelock.WriteFile(lsPath, data)
if err != nil {
logging.LogErrorf("write storage [criteria] failed: %s", err)
return
}
return
}
func getCriteria() (ret []*Criterion, err error) {
ret = []*Criterion{}
dataPath := filepath.Join(util.DataDir, "storage/criteria.json")
if !filelock.IsExist(dataPath) {
return
}
data, err := filelock.ReadFile(dataPath)
if err != nil {
logging.LogErrorf("read storage [criteria] failed: %s", err)
return
}
if err = gulu.JSON.UnmarshalJSON(data, &ret); err != nil {
logging.LogErrorf("unmarshal storage [criteria] failed: %s", err)
return
}
return
}
var localStorageLock = sync.Mutex{}
func RemoveLocalStorageVals(keys []string) (err error) {
localStorageLock.Lock()
defer localStorageLock.Unlock()
localStorage := getLocalStorage()
for _, key := range keys {
delete(localStorage, key)
}
return setLocalStorage(localStorage)
}
func SetLocalStorageVal(key string, val interface{}) (err error) {
localStorageLock.Lock()
defer localStorageLock.Unlock()
localStorage := getLocalStorage()
localStorage[key] = val
return setLocalStorage(localStorage)
}
func SetLocalStorage(val interface{}) (err error) {
localStorageLock.Lock()
defer localStorageLock.Unlock()
return setLocalStorage(val)
}
func GetLocalStorage() (ret map[string]interface{}) {
localStorageLock.Lock()
defer localStorageLock.Unlock()
return getLocalStorage()
}
func setLocalStorage(val interface{}) (err error) {
if util.ReadOnly {
return
}
dirPath := filepath.Join(util.DataDir, "storage")
if err = os.MkdirAll(dirPath, 0755); err != nil {
logging.LogErrorf("create storage [local] dir failed: %s", err)
return
}
data, err := gulu.JSON.MarshalIndentJSON(val, "", " ")
if err != nil {
logging.LogErrorf("marshal storage [local] failed: %s", err)
return
}
lsPath := filepath.Join(dirPath, "local.json")
err = filelock.WriteFile(lsPath, data)
if err != nil {
logging.LogErrorf("write storage [local] failed: %s", err)
return
}
return
}
func getLocalStorage() (ret map[string]interface{}) {
// When local.json is corrupted, clear the file to avoid being unable to enter the main interface https://github.com/siyuan-note/siyuan/issues/7911
ret = map[string]interface{}{}
lsPath := filepath.Join(util.DataDir, "storage/local.json")
if !filelock.IsExist(lsPath) {
return
}
data, err := filelock.ReadFile(lsPath)
if err != nil {
logging.LogErrorf("read storage [local] failed: %s", err)
return
}
if err = gulu.JSON.UnmarshalJSON(data, &ret); err != nil {
logging.LogErrorf("unmarshal storage [local] failed: %s", err)
return
}
return
}
var outlineStorageLock = sync.Mutex{}
func GetOutlineStorage(docID string) (ret map[string]interface{}, err error) {
outlineStorageLock.Lock()
defer outlineStorageLock.Unlock()
ret = map[string]interface{}{}
outlineDocs, err := getOutlineDocs()
if err != nil {
return
}
for _, doc := range outlineDocs {
if doc.DocID == docID {
ret = doc.Data
break
}
}
return
}
func SetOutlineStorage(docID string, val interface{}) (err error) {
outlineStorageLock.Lock()
defer outlineStorageLock.Unlock()
outlineDoc := &OutlineDoc{
DocID: docID,
Data: make(map[string]interface{}),
}
if valMap, ok := val.(map[string]interface{}); ok {
outlineDoc.Data = valMap
}
outlineDocs, err := getOutlineDocs()
if err != nil {
return
}
// 如果文档已存在,先移除旧的
for i, doc := range outlineDocs {
if doc.DocID == docID {
outlineDocs = append(outlineDocs[:i], outlineDocs[i+1:]...)
break
}
}
// 将新的文档信息添加到最前面
outlineDocs = append([]*OutlineDoc{outlineDoc}, outlineDocs...)
// 限制为2000个文档
if 2000 < len(outlineDocs) {
outlineDocs = outlineDocs[:2000]
}
err = setOutlineDocs(outlineDocs)
return
}
func RemoveOutlineStorage(docID string) (err error) {
outlineStorageLock.Lock()
defer outlineStorageLock.Unlock()
outlineDocs, err := getOutlineDocs()
if err != nil {
return
}
for i, doc := range outlineDocs {
if doc.DocID == docID {
outlineDocs = append(outlineDocs[:i], outlineDocs[i+1:]...)
break
}
}
err = setOutlineDocs(outlineDocs)
return
}
func setOutlineDocs(outlineDocs []*OutlineDoc) (err error) {
dirPath := filepath.Join(util.DataDir, "storage")
if err = os.MkdirAll(dirPath, 0755); err != nil {
logging.LogErrorf("create storage [outline] dir failed: %s", err)
return
}
data, err := gulu.JSON.MarshalJSON(outlineDocs)
if err != nil {
logging.LogErrorf("marshal storage [outline] failed: %s", err)
return
}
lsPath := filepath.Join(dirPath, "outline.json")
err = filelock.WriteFile(lsPath, data)
if err != nil {
logging.LogErrorf("write storage [outline] failed: %s", err)
return
}
return
}
func getOutlineDocs() (ret []*OutlineDoc, err error) {
ret = []*OutlineDoc{}
dataPath := filepath.Join(util.DataDir, "storage/outline.json")
if !filelock.IsExist(dataPath) {
return
}
data, err := filelock.ReadFile(dataPath)
if err != nil {
logging.LogErrorf("read storage [outline] failed: %s", err)
return
}
if err = gulu.JSON.UnmarshalJSON(data, &ret); err != nil {
logging.LogErrorf("unmarshal storage [outline] failed: %s", err)
return
}
return
}