From fe87cf521af8fc2a16467d03152cebba35a5ca6d Mon Sep 17 00:00:00 2001 From: Jeffrey Chen <78434827+TCOTC@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:07:41 +0800 Subject: [PATCH 1/5] :recycle: Refactor recent documents handling --- app/src/business/openRecentDocs.ts | 111 +++++-------- app/src/layout/Wnd.ts | 16 +- app/src/layout/tabUtil.ts | 55 ++++-- kernel/api/router.go | 1 + kernel/api/storage.go | 27 ++- kernel/model/storage.go | 258 +++++++++++++++++++++++------ 6 files changed, 329 insertions(+), 139 deletions(-) diff --git a/app/src/business/openRecentDocs.ts b/app/src/business/openRecentDocs.ts index 5e1f421afbc..f1e7b3805b2 100644 --- a/app/src/business/openRecentDocs.ts +++ b/app/src/business/openRecentDocs.ts @@ -10,44 +10,39 @@ import {focusByRange} from "../protyle/util/selection"; import {hasClosestByClassName} from "../protyle/util/hasClosest"; import {hideElements} from "../protyle/ui/hideElements"; -const getHTML = async (data: { +const renderRecentDocsContent = async (data: { rootID: string, icon: string, title: string, viewedAt?: number, closedAt?: number, openAt?: number, - updated?: number -}[], element: Element, key?: string, sortBy: TRecentDocsSort = "viewedAt") => { +}[], element: Element, key?: string) => { let tabHtml = ""; let index = 0; - // 根据排序字段对数据进行排序 - const sortedData = [...data].sort((a, b) => { - const aValue = a[sortBy] || 0; - const bValue = b[sortBy] || 0; - return bValue - aValue; // 降序排序 - }); - - sortedData.forEach((item) => { - if (!key || item.title.toLowerCase().includes(key.toLowerCase())) { - tabHtml += `
  • -${unicode2Emoji(item.icon || window.siyuan.storage[Constants.LOCAL_IMAGES].file, "b3-list-item__graphic", true)} -${escapeHtml(item.title)} + if (key) { + data = data.filter((item) => { + return item.title.toLowerCase().includes(key.toLowerCase()); + }); + } + data.forEach((item) => { + tabHtml += `
  • + ${unicode2Emoji(item.icon || window.siyuan.storage[Constants.LOCAL_IMAGES].file, "b3-list-item__graphic", true)} + ${escapeHtml(item.title)}
  • `; - index++; - } + index++; }); let switchPath = ""; if (tabHtml) { const pathResponse = await fetchSyncPost("/api/filetree/getFullHPathByID", { - id: data[0].rootID + id: data[0].rootID // 过滤后的第一个文档 ID }); switchPath = escapeHtml(pathResponse.data); } let dockHtml = ""; if (!isWindow()) { - dockHtml = '"; + dockHtml = '"; } const pathElement = element.querySelector(".switch-doc__path"); pathElement.innerHTML = switchPath; - pathElement.previousElementSibling.innerHTML = `
    - ${dockHtml} - -
    `; + pathElement.previousElementSibling.innerHTML = `
    + ${dockHtml} + +
    `; }; export const openRecentDocs = () => { @@ -93,7 +88,8 @@ export const openRecentDocs = () => { hideElements(["dialog"]); return; } - fetchPost("/api/storage/getRecentDocs", {sortBy: "viewedAt"}, (response) => { + const sortBy = window.siyuan.storage[Constants.LOCAL_RECENT_DOCS].type; + fetchPost("/api/storage/getRecentDocs", {sortBy}, (response) => { let range: Range; if (getSelection().rangeCount > 0) { range = getSelection().getRangeAt(0); @@ -110,15 +106,15 @@ export const openRecentDocs = () => {
    `, content: `
    -
    +
    `, height: "80vh", @@ -128,16 +124,18 @@ export const openRecentDocs = () => { } } }); + const sortSelect = dialog.element.querySelector("#recentDocsSort") as HTMLSelectElement; + sortSelect.value = sortBy; const searchElement = dialog.element.querySelector("input"); searchElement.focus(); searchElement.addEventListener("compositionend", () => { - getHTML(response.data, dialog.element, searchElement.value, sortSelect.value as TRecentDocsSort); + renderRecentDocsContent(response.data, dialog.element, searchElement.value); }); searchElement.addEventListener("input", (event: InputEvent) => { if (event.isComposing) { return; } - getHTML(response.data, dialog.element, searchElement.value, sortSelect.value as TRecentDocsSort); + renderRecentDocsContent(response.data, dialog.element, searchElement.value); }); dialog.element.setAttribute("data-key", Constants.DIALOG_RECENTDOCS); dialog.element.addEventListener("click", (event) => { @@ -152,45 +150,16 @@ export const openRecentDocs = () => { }); // 添加排序下拉框事件监听 - const sortSelect = dialog.element.querySelector("#recentDocsSort") as HTMLSelectElement; sortSelect.addEventListener("change", () => { - // 重新调用API获取排序后的数据 - if (sortSelect.value === "updated") { - // 使用SQL查询获取最近修改的文档 - const data = { - stmt: "SELECT * FROM blocks WHERE type = 'd' ORDER BY updated DESC LIMIT 33" - }; - fetchSyncPost("/api/query/sql", data).then((sqlResponse) => { - if (sqlResponse.data && sqlResponse.data.length > 0) { - // 转换SQL查询结果格式 - const recentModifiedDocs = sqlResponse.data.map((block: any) => { - // 从ial中解析icon - let icon = ""; - if (block.ial) { - const iconMatch = block.ial.match(/icon="([^"]*)"/); - if (iconMatch) { - icon = iconMatch[1]; - } - } - return { - rootID: block.id, - icon, - title: block.content, - updated: block.updated - }; - }); - getHTML(recentModifiedDocs, dialog.element, searchElement.value, "updated"); - } - }); - } else { - fetchPost("/api/storage/getRecentDocs", {sortBy: sortSelect.value}, (newResponse) => { - getHTML(newResponse.data, dialog.element, searchElement.value, sortSelect.value as TRecentDocsSort); - }); - } + // 重新调用 API 获取排序后的数据 + fetchPost("/api/storage/getRecentDocs", {sortBy: sortSelect.value}, (newResponse) => { + response = newResponse; + renderRecentDocsContent(newResponse.data, dialog.element, searchElement.value); + }); window.siyuan.storage[Constants.LOCAL_RECENT_DOCS].type = sortSelect.value; setStorageVal(Constants.LOCAL_RECENT_DOCS, window.siyuan.storage[Constants.LOCAL_RECENT_DOCS]); }); - getHTML(response.data, dialog.element); + renderRecentDocsContent(response.data, dialog.element); }); }; diff --git a/app/src/layout/Wnd.ts b/app/src/layout/Wnd.ts index 5e8e636c6af..878fb5d38c1 100644 --- a/app/src/layout/Wnd.ts +++ b/app/src/layout/Wnd.ts @@ -778,7 +778,7 @@ export class Wnd { model.send("closews", {}); } - private removeTabAction = (id: string, closeAll = false, animate = true, isSaveLayout = true) => { + private removeTabAction = (id: string, isBatchClose = false, animate = true, isSaveLayout = true) => { clearCounter(); this.children.find((item, index) => { if (item.id === id) { @@ -794,8 +794,10 @@ export class Wnd { } if (item.model instanceof Editor) { saveScroll(item.model.editor.protyle); - // 更新文档关闭时间 - fetchPost("/api/storage/updateRecentDocCloseTime", {rootID: item.model.editor.protyle.block.rootID}); + // 更新文档关闭时间(批量关闭页签时由 closeTabByType 批量处理,这里不单独调用) + if (!isBatchClose) { + fetchPost("/api/storage/updateRecentDocCloseTime", {rootID: item.model.editor.protyle.block.rootID}); + } } if (this.children.length === 1) { this.destroyModel(this.children[0].model); @@ -841,7 +843,7 @@ export class Wnd { } } }); - if (latestHeadElement && !closeAll) { + if (latestHeadElement && !isBatchClose) { this.switchTab(latestHeadElement, true, true, false, false); this.showHeading(); } @@ -889,7 +891,7 @@ export class Wnd { /// #endif }; - public removeTab(id: string, closeAll = false, animate = true, isSaveLayout = true) { + public removeTab(id: string, isBatchClose = false, animate = true, isSaveLayout = true) { for (let index = 0; index < this.children.length; index++) { const item = this.children[index]; if (item.id === id) { @@ -898,9 +900,9 @@ export class Wnd { showMessage(window.siyuan.languages.uploading); return; } - this.removeTabAction(id, closeAll, animate, isSaveLayout); + this.removeTabAction(id, isBatchClose, animate, isSaveLayout); } else { - this.removeTabAction(id, closeAll, animate, isSaveLayout); + this.removeTabAction(id, isBatchClose, animate, isSaveLayout); } return; } diff --git a/app/src/layout/tabUtil.ts b/app/src/layout/tabUtil.ts index eb4d229bdd3..c45d68e19c6 100644 --- a/app/src/layout/tabUtil.ts +++ b/app/src/layout/tabUtil.ts @@ -23,6 +23,7 @@ import {openHistory} from "../history/history"; import {newFile} from "../util/newFile"; import {mountHelp, newNotebook} from "../util/mount"; import {Constants} from "../constants"; +import {fetchPost} from "../util/fetch"; export const getActiveTab = (wndActive = true) => { const activeTabElement = document.querySelector(".layout__wnd--active .item--focus"); @@ -360,28 +361,58 @@ export const copyTab = (app: App, tab: Tab) => { }; export const closeTabByType = async (tab: Tab, type: "closeOthers" | "closeAll" | "other", tabs?: Tab[]) => { + const tabsToClose: Tab[] = []; if (type === "closeOthers") { - for (let index = 0; index < tab.parent.children.length; index++) { - if (tab.parent.children[index].id !== tab.id && !tab.parent.children[index].headElement.classList.contains("item--pin")) { - await tab.parent.children[index].parent.removeTab(tab.parent.children[index].id, true, false); - index--; + for (const item of tab.parent.children) { + if (item.id !== tab.id && !item.headElement.classList.contains("item--pin")) { + tabsToClose.push(item); } } } else if (type === "closeAll") { - for (let index = 0; index < tab.parent.children.length; index++) { - if (!tab.parent.children[index].headElement.classList.contains("item--pin")) { - await tab.parent.children[index].parent.removeTab(tab.parent.children[index].id, true); - index--; + for (const item of tab.parent.children) { + if (!item.headElement.classList.contains("item--pin")) { + tabsToClose.push(item); } } - } else if (tabs.length > 0) { - for (let index = 0; index < tabs.length; index++) { - if (!tabs[index].headElement.classList.contains("item--pin")) { - await tabs[index].parent.removeTab(tabs[index].id); + } else if (tabs && tabs.length > 0) { + for (const item of tabs) { + if (!item.headElement.classList.contains("item--pin")) { + tabsToClose.push(item); } } } + // 收集所有需要关闭的文档 rootID 并批量关闭页签 + const rootIDs: string[] = []; + for (const item of tabsToClose) { + let rootID; + if (item.model instanceof Editor) { + rootID = item.model.editor.protyle.block.rootID; + } else if (!item.model) { + const initTab = item.headElement.getAttribute("data-initdata"); + if (initTab) { + const initTabData = JSON.parse(initTab); + if (initTabData && initTabData.instance === "Editor" && initTabData.rootId) { + rootID = initTabData.rootId; + } + } + } + if (rootID) { + rootIDs.push(rootID); + } + + if (type === "closeOthers") { + item.parent.removeTab(item.id, true, false); + } else { + item.parent.removeTab(item.id, true); + } + } + + // 批量更新文档关闭时间 + if (rootIDs.length > 0) { + fetchPost("/api/storage/batchUpdateRecentDocCloseTime", {rootIDs}); + } + if (tab.headElement.parentElement && !tab.headElement.parentElement.querySelector(".item--focus")) { tab.parent.switchTab(tab.headElement, true); } else if (tab.parent.children.length > 0) { diff --git a/kernel/api/router.go b/kernel/api/router.go index 7b5f603d963..f41ba11703a 100644 --- a/kernel/api/router.go +++ b/kernel/api/router.go @@ -80,6 +80,7 @@ func ServeAPI(ginServer *gin.Engine) { ginServer.Handle("POST", "/api/storage/getRecentDocs", model.CheckAuth, getRecentDocs) 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) diff --git a/kernel/api/storage.go b/kernel/api/storage.go index fa223bc61b2..5f68e63d4bb 100644 --- a/kernel/api/storage.go +++ b/kernel/api/storage.go @@ -293,11 +293,11 @@ func updateRecentDocCloseTime(c *gin.Context) { return } - if nil == arg["rootID"] { + rootID, ok := arg["rootID"].(string) + if !ok || rootID == "" { return } - rootID := arg["rootID"].(string) err := model.UpdateRecentDocCloseTime(rootID) if err != nil { ret.Code = -1 @@ -305,3 +305,26 @@ func updateRecentDocCloseTime(c *gin.Context) { return } } + +func batchUpdateRecentDocCloseTime(c *gin.Context) { + ret := gulu.Ret.NewResult() + defer c.JSON(http.StatusOK, ret) + + arg, ok := util.JsonArg(c, ret) + if !ok { + return + } + + rootIDsArg := arg["rootIDs"].([]interface{}) + var rootIDs []string + for _, id := range rootIDsArg { + rootIDs = append(rootIDs, id.(string)) + } + + err := model.BatchUpdateRecentDocCloseTime(rootIDs) + if err != nil { + ret.Code = -1 + ret.Msg = err.Error() + return + } +} diff --git a/kernel/model/storage.go b/kernel/model/storage.go index 25e3cffdf16..9a1eea4e24c 100644 --- a/kernel/model/storage.go +++ b/kernel/model/storage.go @@ -22,6 +22,7 @@ import ( "path" "path/filepath" "sort" + "strings" "sync" "time" @@ -29,14 +30,15 @@ import ( "github.com/88250/lute/parse" "github.com/siyuan-note/filelock" "github.com/siyuan-note/logging" + "github.com/siyuan-note/siyuan/kernel/sql" "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"` + Icon string `json:"icon,omitempty"` + Title string `json:"title,omitempty"` ViewedAt int64 `json:"viewedAt"` // 浏览时间字段 ClosedAt int64 `json:"closedAt"` // 关闭时间字段 OpenAt int64 `json:"openAt"` // 文档第一次从文档树加载到页签的时间 @@ -49,6 +51,73 @@ type OutlineDoc struct { var recentDocLock = sync.Mutex{} +// 三种类型各保留 32 条记录 +func trimRecentDocs(recentDocs []*RecentDoc) []*RecentDoc { + if len(recentDocs) <= 32 { + return recentDocs + } + + // 分别统计三种类型的记录 + var viewedDocs []*RecentDoc + var openedDocs []*RecentDoc + var closedDocs []*RecentDoc + + for _, doc := range recentDocs { + if doc.ViewedAt > 0 { + viewedDocs = append(viewedDocs, doc) + } + if doc.OpenAt > 0 { + openedDocs = append(openedDocs, doc) + } + if doc.ClosedAt > 0 { + closedDocs = append(closedDocs, doc) + } + } + + // 分别按时间排序并截取 32 条记录 + if len(viewedDocs) > 32 { + sort.Slice(viewedDocs, func(i, j int) bool { + return viewedDocs[i].ViewedAt > viewedDocs[j].ViewedAt + }) + viewedDocs = viewedDocs[:32] + } + if len(openedDocs) > 32 { + sort.Slice(openedDocs, func(i, j int) bool { + return openedDocs[i].OpenAt > openedDocs[j].OpenAt + }) + openedDocs = openedDocs[:32] + } + if len(closedDocs) > 32 { + sort.Slice(closedDocs, func(i, j int) bool { + return closedDocs[i].ClosedAt > closedDocs[j].ClosedAt + }) + closedDocs = closedDocs[:32] + } + + // 合并三类记录 + docMap := make(map[string]*RecentDoc, 64) + for _, doc := range viewedDocs { + docMap[doc.RootID] = doc + } + for _, doc := range openedDocs { + if _, ok := docMap[doc.RootID]; !ok { + docMap[doc.RootID] = doc + } + } + for _, doc := range closedDocs { + if _, ok := docMap[doc.RootID]; !ok { + docMap[doc.RootID] = doc + } + } + + result := make([]*RecentDoc, 0, len(docMap)) + for _, doc := range docMap { + result = append(result, doc) + } + + return result +} + func RemoveRecentDoc(ids []string) { recentDocLock.Lock() defer recentDocLock.Unlock() @@ -70,14 +139,13 @@ func RemoveRecentDoc(ids []string) { 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"), + Icon: "", + Title: "", ViewedAt: time.Now().Unix(), // 使用当前时间作为浏览时间 ClosedAt: 0, // 初始化关闭时间为0,表示未关闭 OpenAt: time.Now().Unix(), // 设置文档打开时间 @@ -93,18 +161,20 @@ func setRecentDocByTree(tree *parse.Tree) { for i, c := range recentDocs { if c.RootID == recentDoc.RootID { + recentDoc.ClosedAt = c.ClosedAt recentDocs = append(recentDocs[:i], recentDocs[i+1:]...) break } } recentDocs = append([]*RecentDoc{recentDoc}, recentDocs...) - if 32 < len(recentDocs) { - recentDocs = recentDocs[:32] - } + + recentDocs = trimRecentDocs(recentDocs) err = setRecentDocs(recentDocs) - return + if err != nil { + return + } } // UpdateRecentDocOpenTime 更新文档打开时间(只在第一次从文档树加载到页签时调用) @@ -122,6 +192,7 @@ func UpdateRecentDocOpenTime(rootID string) (err error) { for _, doc := range recentDocs { if doc.RootID == rootID { doc.OpenAt = time.Now().Unix() + doc.ClosedAt = 0 found = true break } @@ -148,6 +219,7 @@ func UpdateRecentDocViewTime(rootID string) (err error) { for _, doc := range recentDocs { if doc.RootID == rootID { doc.ViewedAt = time.Now().Unix() + doc.ClosedAt = 0 found = true break } @@ -165,6 +237,15 @@ func UpdateRecentDocViewTime(rootID string) (err error) { // UpdateRecentDocCloseTime 更新文档关闭时间 func UpdateRecentDocCloseTime(rootID string) (err error) { + return BatchUpdateRecentDocCloseTime([]string{rootID}) +} + +// BatchUpdateRecentDocCloseTime 批量更新文档关闭时间 +func BatchUpdateRecentDocCloseTime(rootIDs []string) (err error) { + if len(rootIDs) == 0 { + return + } + recentDocLock.Lock() defer recentDocLock.Unlock() @@ -173,17 +254,45 @@ func UpdateRecentDocCloseTime(rootID string) (err error) { return } - // 查找文档并更新关闭时间 - found := false + rootIDs = gulu.Str.RemoveDuplicatedElem(rootIDs) + rootIDsMap := make(map[string]bool, len(rootIDs)) + for _, id := range rootIDs { + rootIDsMap[id] = true + } + + closeTime := time.Now().Unix() + + // 更新已存在的文档 + updated := false for _, doc := range recentDocs { - if doc.RootID == rootID { - doc.ClosedAt = time.Now().Unix() - found = true - break + if rootIDsMap[doc.RootID] { + doc.ClosedAt = closeTime + updated = true + delete(rootIDsMap, doc.RootID) // 标记已处理 } } - if found { + // 为不存在的文档创建新记录 + for rootID := range rootIDsMap { + tree, loadErr := LoadTreeByBlockID(rootID) + if loadErr != nil { + continue + } + + recentDoc := &RecentDoc{ + RootID: tree.Root.ID, + Icon: tree.Root.IALAttr("icon"), + Title: tree.Root.IALAttr("title"), + ViewedAt: 0, // 未浏览过,设为 0 + ClosedAt: closeTime, // 设置关闭时间 + OpenAt: 0, // 未记录打开时间,设为 0 + } + + recentDocs = append([]*RecentDoc{recentDoc}, recentDocs...) + updated = true + } + + if updated { err = setRecentDocs(recentDocs) } return @@ -202,6 +311,12 @@ func setRecentDocs(recentDocs []*RecentDoc) (err error) { return } + // 不保存 Title 和 Icon + for _, doc := range recentDocs { + doc.Title = "" + doc.Icon = "" + } + data, err := gulu.JSON.MarshalIndentJSON(recentDocs, "", " ") if err != nil { logging.LogErrorf("marshal storage [recent-doc] failed: %s", err) @@ -218,7 +333,7 @@ func setRecentDocs(recentDocs []*RecentDoc) (err error) { } func getRecentDocs(sortBy string) (ret []*RecentDoc, err error) { - tmp := []*RecentDoc{} + var tmp []*RecentDoc dataPath := filepath.Join(util.DataDir, "storage/recent-doc.json") if !filelock.IsExist(dataPath) { return @@ -247,7 +362,12 @@ func getRecentDocs(sortBy string) (ret []*RecentDoc, err error) { 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 + ial := sql.GetBlockAttrs(doc.RootID) + if "" != ial["icon"] { + doc.Icon = ial["icon"] + } ret = append(ret, doc) } else { notExists = append(notExists, doc.RootID) @@ -255,51 +375,95 @@ func getRecentDocs(sortBy string) (ret []*RecentDoc, err error) { } if 0 < len(notExists) { - setRecentDocs(ret) + err := setRecentDocs(ret) + if err != nil { + return nil, err + } } // 根据排序参数进行排序 switch sortBy { - case "closedAt": // 按关闭时间排序 - sort.Slice(ret, func(i, j int) bool { - if ret[i].ClosedAt == 0 && ret[j].ClosedAt == 0 { - // 如果都没有关闭时间,按浏览时间排序 - return ret[i].ViewedAt > ret[j].ViewedAt + case "updated": // 按更新时间排序 + // 从数据库查询最近修改的文档 + sqlBlocks := sql.SelectBlocksRawStmt("SELECT * FROM blocks WHERE type = 'd' ORDER BY updated DESC", 1, 32) + ret = []*RecentDoc{} + if 1 > len(sqlBlocks) { + return + } + + // 获取文档树信息 + var rootIDs []string + for _, sqlBlock := range sqlBlocks { + rootIDs = append(rootIDs, sqlBlock.ID) + } + bts := treenode.GetBlockTrees(rootIDs) + + for _, sqlBlock := range sqlBlocks { + // 解析 IAL 获取 icon + icon := "" + if sqlBlock.IAL != "" { + ialStr := strings.TrimPrefix(sqlBlock.IAL, "{:") + ialStr = strings.TrimSuffix(ialStr, "}") + ial := parse.Tokens2IAL([]byte(ialStr)) + for _, kv := range ial { + if kv[0] == "icon" { + icon = kv[1] + break + } + } + } + // 获取文档标题 + title := "" + if bt := bts[sqlBlock.ID]; nil != bt { + title = path.Base(bt.HPath) + } + if title == "" { + title = sqlBlock.Content + if title == "" { + title = sqlBlock.HPath + if title == "" { + title = sqlBlock.ID + } + } } - if ret[i].ClosedAt == 0 { - return false // 没有关闭时间的排在后面 + doc := &RecentDoc{ + RootID: sqlBlock.ID, + Icon: icon, + Title: title, } - if ret[j].ClosedAt == 0 { - return true // 有关闭时间的排在前面 + ret = append(ret, doc) + } + case "closedAt": // 按关闭时间排序 + var filtered []*RecentDoc + for _, doc := range ret { + if doc.ClosedAt > 0 { + filtered = append(filtered, doc) } + } + ret = filtered + sort.Slice(ret, func(i, j int) bool { return ret[i].ClosedAt > ret[j].ClosedAt }) case "openAt": // 按打开时间排序 - sort.Slice(ret, func(i, j int) bool { - if ret[i].OpenAt == 0 && ret[j].OpenAt == 0 { - // 如果都没有打开时间,按ID时间排序(ID包含时间信息) - return ret[i].RootID > ret[j].RootID - } - if ret[i].OpenAt == 0 { - return false // 没有打开时间的排在后面 - } - if ret[j].OpenAt == 0 { - return true // 有打开时间的排在前面 + var filtered []*RecentDoc + for _, doc := range ret { + if doc.OpenAt > 0 { + filtered = append(filtered, doc) } + } + ret = filtered + sort.Slice(ret, func(i, j int) bool { return ret[i].OpenAt > ret[j].OpenAt }) default: // 默认按浏览时间排序 - sort.Slice(ret, func(i, j int) bool { - if ret[i].ViewedAt == 0 && ret[j].ViewedAt == 0 { - // 如果都没有浏览时间,按ID时间排序(ID包含时间信息) - return ret[i].RootID > ret[j].RootID - } - if ret[i].ViewedAt == 0 { - return false // 没有浏览时间的排在后面 - } - if ret[j].ViewedAt == 0 { - return true // 有浏览时间的排在前面 + var filtered []*RecentDoc + for _, doc := range ret { + if doc.ViewedAt > 0 { + filtered = append(filtered, doc) } + } + ret = filtered + sort.Slice(ret, func(i, j int) bool { return ret[i].ViewedAt > ret[j].ViewedAt }) } From 92a9214302a7e6d0212ecab6a4829844047fe7a9 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen <78434827+TCOTC@users.noreply.github.com> Date: Wed, 5 Nov 2025 17:28:51 +0800 Subject: [PATCH 2/5] Update RecentDoc struct to make timestamp fields optional --- kernel/model/storage.go | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/kernel/model/storage.go b/kernel/model/storage.go index 9a1eea4e24c..90e18c878db 100644 --- a/kernel/model/storage.go +++ b/kernel/model/storage.go @@ -39,9 +39,9 @@ type RecentDoc struct { RootID string `json:"rootID"` Icon string `json:"icon,omitempty"` Title string `json:"title,omitempty"` - ViewedAt int64 `json:"viewedAt"` // 浏览时间字段 - ClosedAt int64 `json:"closedAt"` // 关闭时间字段 - OpenAt int64 `json:"openAt"` // 文档第一次从文档树加载到页签的时间 + ViewedAt int64 `json:"viewedAt,omitempty"` // 浏览时间字段 + ClosedAt int64 `json:"closedAt,omitempty"` // 关闭时间字段 + OpenAt int64 `json:"openAt,omitempty"` // 文档第一次从文档树加载到页签的时间 } type OutlineDoc struct { @@ -142,13 +142,11 @@ func RemoveRecentDoc(ids []string) { } func setRecentDocByTree(tree *parse.Tree) { + timeNow := time.Now().Unix() recentDoc := &RecentDoc{ RootID: tree.Root.ID, - Icon: "", - Title: "", - ViewedAt: time.Now().Unix(), // 使用当前时间作为浏览时间 - ClosedAt: 0, // 初始化关闭时间为0,表示未关闭 - OpenAt: time.Now().Unix(), // 设置文档打开时间 + ViewedAt: timeNow, // 使用当前时间作为浏览时间 + OpenAt: timeNow, // 设置文档打开时间 } recentDocLock.Lock() @@ -281,11 +279,7 @@ func BatchUpdateRecentDocCloseTime(rootIDs []string) (err error) { recentDoc := &RecentDoc{ RootID: tree.Root.ID, - Icon: tree.Root.IALAttr("icon"), - Title: tree.Root.IALAttr("title"), - ViewedAt: 0, // 未浏览过,设为 0 ClosedAt: closeTime, // 设置关闭时间 - OpenAt: 0, // 未记录打开时间,设为 0 } recentDocs = append([]*RecentDoc{recentDoc}, recentDocs...) From ddbcacfe8143fc59bdde0c968ff4c54623192cfb Mon Sep 17 00:00:00 2001 From: Jeffrey Chen <78434827+TCOTC@users.noreply.github.com> Date: Thu, 6 Nov 2025 19:59:56 +0800 Subject: [PATCH 3/5] GetDoc is solely responsible for retrieving document content and does not handle business logic --- kernel/model/file.go | 1 - kernel/model/storage.go | 92 +++++++++++++++++------------------------ 2 files changed, 39 insertions(+), 54 deletions(-) diff --git a/kernel/model/file.go b/kernel/model/file.go index fe1ec9ba652..5d7b5bc2501 100644 --- a/kernel/model/file.go +++ b/kernel/model/file.go @@ -745,7 +745,6 @@ func GetDoc(startID, endID, id string, index int, query string, queryTypes map[s } keywords = gulu.Str.RemoveDuplicatedElem(keywords) - go setRecentDocByTree(tree) return } diff --git a/kernel/model/storage.go b/kernel/model/storage.go index 90e18c878db..5ee76b48134 100644 --- a/kernel/model/storage.go +++ b/kernel/model/storage.go @@ -51,9 +51,14 @@ type OutlineDoc struct { var recentDocLock = sync.Mutex{} -// 三种类型各保留 32 条记录 +// 三种类型各保留 32 条记录,并清空 Title 和 Icon func trimRecentDocs(recentDocs []*RecentDoc) []*RecentDoc { if len(recentDocs) <= 32 { + // 清空 Title 和 Icon + for _, doc := range recentDocs { + doc.Title = "" + doc.Icon = "" + } return recentDocs } @@ -112,6 +117,9 @@ func trimRecentDocs(recentDocs []*RecentDoc) []*RecentDoc { result := make([]*RecentDoc, 0, len(docMap)) for _, doc := range docMap { + // 清空 Title 和 Icon + doc.Title = "" + doc.Icon = "" result = append(result, doc) } @@ -141,40 +149,6 @@ func RemoveRecentDoc(ids []string) { } } -func setRecentDocByTree(tree *parse.Tree) { - timeNow := time.Now().Unix() - recentDoc := &RecentDoc{ - RootID: tree.Root.ID, - ViewedAt: timeNow, // 使用当前时间作为浏览时间 - OpenAt: timeNow, // 设置文档打开时间 - } - - recentDocLock.Lock() - defer recentDocLock.Unlock() - - recentDocs, err := getRecentDocs("") - if err != nil { - return - } - - for i, c := range recentDocs { - if c.RootID == recentDoc.RootID { - recentDoc.ClosedAt = c.ClosedAt - recentDocs = append(recentDocs[:i], recentDocs[i+1:]...) - break - } - } - - recentDocs = append([]*RecentDoc{recentDoc}, recentDocs...) - - recentDocs = trimRecentDocs(recentDocs) - - err = setRecentDocs(recentDocs) - if err != nil { - return - } -} - // UpdateRecentDocOpenTime 更新文档打开时间(只在第一次从文档树加载到页签时调用) func UpdateRecentDocOpenTime(rootID string) (err error) { recentDocLock.Lock() @@ -185,20 +159,30 @@ func UpdateRecentDocOpenTime(rootID string) (err error) { return } - // 查找文档并更新打开时间 + timeNow := time.Now().Unix() + // 查找文档并更新打开时间和浏览时间 found := false for _, doc := range recentDocs { if doc.RootID == rootID { - doc.OpenAt = time.Now().Unix() + doc.OpenAt = timeNow + doc.ViewedAt = timeNow doc.ClosedAt = 0 found = true break } } - if found { - err = setRecentDocs(recentDocs) + // 如果文档不存在,创建新记录 + if !found { + recentDoc := &RecentDoc{ + RootID: rootID, + OpenAt: timeNow, + ViewedAt: timeNow, + } + recentDocs = append([]*RecentDoc{recentDoc}, recentDocs...) } + + err = setRecentDocs(recentDocs) return } @@ -212,24 +196,30 @@ func UpdateRecentDocViewTime(rootID string) (err error) { return } - // 查找文档并更新浏览时间 + timeNow := time.Now().Unix() + // 查找文档并更新浏览时间,保留原来的打开时间 found := false for _, doc := range recentDocs { if doc.RootID == rootID { - doc.ViewedAt = time.Now().Unix() + // OpenAt 保持不变,保留原来的打开时间 + doc.ViewedAt = timeNow doc.ClosedAt = 0 found = true break } } - if found { - // 按浏览时间降序排序 - sort.Slice(recentDocs, func(i, j int) bool { - return recentDocs[i].ViewedAt > recentDocs[j].ViewedAt - }) - err = setRecentDocs(recentDocs) + // 如果文档不存在,创建新记录 + if !found { + recentDoc := &RecentDoc{ + RootID: rootID, + // 新创建的记录不设置 OpenAt,因为这是浏览而不是打开 + ViewedAt: timeNow, + } + recentDocs = append([]*RecentDoc{recentDoc}, recentDocs...) } + + err = setRecentDocs(recentDocs) return } @@ -299,18 +289,14 @@ func GetRecentDocs(sortBy string) (ret []*RecentDoc, err error) { } func setRecentDocs(recentDocs []*RecentDoc) (err error) { + recentDocs = trimRecentDocs(recentDocs) + 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 } - // 不保存 Title 和 Icon - for _, doc := range recentDocs { - doc.Title = "" - doc.Icon = "" - } - data, err := gulu.JSON.MarshalIndentJSON(recentDocs, "", " ") if err != nil { logging.LogErrorf("marshal storage [recent-doc] failed: %s", err) From f6217c0b5049876f11900f6182e8a1f0aaa27d3b Mon Sep 17 00:00:00 2001 From: Jeffrey Chen <78434827+TCOTC@users.noreply.github.com> Date: Thu, 6 Nov 2025 20:09:20 +0800 Subject: [PATCH 4/5] Remove RemoveRecentDoc function and its calls from multiple files to streamline document handling --- kernel/model/file.go | 1 - kernel/model/heading.go | 1 - kernel/model/index.go | 3 +-- kernel/model/storage.go | 23 ----------------------- 4 files changed, 1 insertion(+), 27 deletions(-) diff --git a/kernel/model/file.go b/kernel/model/file.go index 5d7b5bc2501..b66d97c99d8 100644 --- a/kernel/model/file.go +++ b/kernel/model/file.go @@ -1596,7 +1596,6 @@ func removeDoc(box *Box, p string, luteEngine *lute.Lute) { logging.LogInfof("removed doc [%s%s]", box.ID, p) box.removeSort(removeIDs) - RemoveRecentDoc(removeIDs) if "/" != dir { others, err := os.ReadDir(filepath.Join(util.DataDir, box.ID, dir)) if err == nil && 1 > len(others) { diff --git a/kernel/model/heading.go b/kernel/model/heading.go index 89d94e5dc8f..1a62f647f0f 100644 --- a/kernel/model/heading.go +++ b/kernel/model/heading.go @@ -284,7 +284,6 @@ func Doc2Heading(srcID, targetID string, after bool) (srcTreeBox, srcTreePath st logging.LogWarnf("remove tree [%s] failed: %s", srcTree.Path, removeErr) } box.removeSort([]string{srcTree.ID}) - RemoveRecentDoc([]string{srcTree.ID}) evt := util.NewCmdResult("removeDoc", 0, util.PushModeBroadcast) evt.Data = map[string]interface{}{ "ids": []string{srcTree.ID}, diff --git a/kernel/model/index.go b/kernel/model/index.go index f937dbb9bf4..53a858a7313 100644 --- a/kernel/model/index.go +++ b/kernel/model/index.go @@ -113,8 +113,7 @@ func (box *Box) Unindex() { } func unindex(boxID string) { - ids := treenode.RemoveBlockTreesByBoxID(boxID) - RemoveRecentDoc(ids) + treenode.RemoveBlockTreesByBoxID(boxID) sql.DeleteBoxQueue(boxID) } diff --git a/kernel/model/storage.go b/kernel/model/storage.go index 5ee76b48134..c908e2dc88b 100644 --- a/kernel/model/storage.go +++ b/kernel/model/storage.go @@ -126,29 +126,6 @@ func trimRecentDocs(recentDocs []*RecentDoc) []*RecentDoc { return result } -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 - } -} - // UpdateRecentDocOpenTime 更新文档打开时间(只在第一次从文档树加载到页签时调用) func UpdateRecentDocOpenTime(rootID string) (err error) { recentDocLock.Lock() From 6419f5c7fb71768dc9d852c064960331d03da9b4 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen <78434827+TCOTC@users.noreply.github.com> Date: Fri, 7 Nov 2025 01:19:36 +0800 Subject: [PATCH 5/5] Ensure the API correctly returns an empty array, add deduplication logic, and remove redundant sorting steps when updating fields --- kernel/model/storage.go | 95 ++++++++++++++++++++++++++++------------- 1 file changed, 65 insertions(+), 30 deletions(-) diff --git a/kernel/model/storage.go b/kernel/model/storage.go index c908e2dc88b..e798406bb4c 100644 --- a/kernel/model/storage.go +++ b/kernel/model/storage.go @@ -51,15 +51,25 @@ type OutlineDoc struct { var recentDocLock = sync.Mutex{} -// 三种类型各保留 32 条记录,并清空 Title 和 Icon -func trimRecentDocs(recentDocs []*RecentDoc) []*RecentDoc { - if len(recentDocs) <= 32 { +// normalizeRecentDocs 规范化最近文档列表:去重、清空 Title/Icon、按类型截取 32 条记录 +func normalizeRecentDocs(recentDocs []*RecentDoc) []*RecentDoc { + // 去重 + seen := make(map[string]struct{}, len(recentDocs)) + deduplicated := make([]*RecentDoc, 0, len(recentDocs)) + for _, doc := range recentDocs { + if _, ok := seen[doc.RootID]; !ok { + seen[doc.RootID] = struct{}{} + deduplicated = append(deduplicated, doc) + } + } + + if len(deduplicated) <= 32 { // 清空 Title 和 Icon - for _, doc := range recentDocs { + for _, doc := range deduplicated { doc.Title = "" doc.Icon = "" } - return recentDocs + return deduplicated } // 分别统计三种类型的记录 @@ -67,7 +77,7 @@ func trimRecentDocs(recentDocs []*RecentDoc) []*RecentDoc { var openedDocs []*RecentDoc var closedDocs []*RecentDoc - for _, doc := range recentDocs { + for _, doc := range deduplicated { if doc.ViewedAt > 0 { viewedDocs = append(viewedDocs, doc) } @@ -131,7 +141,7 @@ func UpdateRecentDocOpenTime(rootID string) (err error) { recentDocLock.Lock() defer recentDocLock.Unlock() - recentDocs, err := getRecentDocs("") + recentDocs, err := loadRecentDocsRaw() if err != nil { return } @@ -168,7 +178,7 @@ func UpdateRecentDocViewTime(rootID string) (err error) { recentDocLock.Lock() defer recentDocLock.Unlock() - recentDocs, err := getRecentDocs("") + recentDocs, err := loadRecentDocsRaw() if err != nil { return } @@ -214,7 +224,7 @@ func BatchUpdateRecentDocCloseTime(rootIDs []string) (err error) { recentDocLock.Lock() defer recentDocLock.Unlock() - recentDocs, err := getRecentDocs("") + recentDocs, err := loadRecentDocsRaw() if err != nil { return } @@ -266,7 +276,7 @@ func GetRecentDocs(sortBy string) (ret []*RecentDoc, err error) { } func setRecentDocs(recentDocs []*RecentDoc) (err error) { - recentDocs = trimRecentDocs(recentDocs) + recentDocs = normalizeRecentDocs(recentDocs) dirPath := filepath.Join(util.DataDir, "storage") if err = os.MkdirAll(dirPath, 0755); err != nil { @@ -289,8 +299,7 @@ func setRecentDocs(recentDocs []*RecentDoc) (err error) { return } -func getRecentDocs(sortBy string) (ret []*RecentDoc, err error) { - var tmp []*RecentDoc +func loadRecentDocsRaw() (ret []*RecentDoc, err error) { dataPath := filepath.Join(util.DataDir, "storage/recent-doc.json") if !filelock.IsExist(dataPath) { return @@ -302,7 +311,7 @@ func getRecentDocs(sortBy string) (ret []*RecentDoc, err error) { return } - if err = gulu.JSON.UnmarshalJSON(data, &tmp); err != nil { + if err = gulu.JSON.UnmarshalJSON(data, &ret); err != nil { logging.LogErrorf("unmarshal storage [recent-doc] failed: %s", err) if err = setRecentDocs([]*RecentDoc{}); err != nil { logging.LogErrorf("reset storage [recent-doc] failed: %s", err) @@ -310,14 +319,33 @@ func getRecentDocs(sortBy string) (ret []*RecentDoc, err error) { ret = []*RecentDoc{} return } + return +} + +func getRecentDocs(sortBy string) (ret []*RecentDoc, err error) { + ret = []*RecentDoc{} // 初始化为空切片,确保 API 始终返回非 nil + recentDocs, err := loadRecentDocsRaw() + if err != nil { + return + } + + // 去重 + seen := make(map[string]struct{}, len(recentDocs)) + var deduplicated []*RecentDoc + for _, doc := range recentDocs { + if _, ok := seen[doc.RootID]; !ok { + seen[doc.RootID] = struct{}{} + deduplicated = append(deduplicated, doc) + } + } var rootIDs []string - for _, doc := range tmp { + for _, doc := range deduplicated { rootIDs = append(rootIDs, doc.RootID) } bts := treenode.GetBlockTrees(rootIDs) var notExists []string - for _, doc := range tmp { + for _, doc := range deduplicated { 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 @@ -332,9 +360,9 @@ func getRecentDocs(sortBy string) (ret []*RecentDoc, err error) { } if 0 < len(notExists) { - err := setRecentDocs(ret) + err = setRecentDocs(ret) if err != nil { - return nil, err + return } } @@ -391,38 +419,45 @@ func getRecentDocs(sortBy string) (ret []*RecentDoc, err error) { ret = append(ret, doc) } case "closedAt": // 按关闭时间排序 - var filtered []*RecentDoc + filtered := []*RecentDoc{} // 初始化为空切片,确保 API 始终返回非 nil for _, doc := range ret { if doc.ClosedAt > 0 { filtered = append(filtered, doc) } } ret = filtered - sort.Slice(ret, func(i, j int) bool { - return ret[i].ClosedAt > ret[j].ClosedAt - }) + if 0 < len(ret) { + sort.Slice(ret, func(i, j int) bool { + return ret[i].ClosedAt > ret[j].ClosedAt + }) + } case "openAt": // 按打开时间排序 - var filtered []*RecentDoc + filtered := []*RecentDoc{} // 初始化为空切片,确保 API 始终返回非 nil for _, doc := range ret { if doc.OpenAt > 0 { filtered = append(filtered, doc) } } ret = filtered - sort.Slice(ret, func(i, j int) bool { - return ret[i].OpenAt > ret[j].OpenAt - }) - default: // 默认按浏览时间排序 - var filtered []*RecentDoc + if 0 < len(ret) { + sort.Slice(ret, func(i, j int) bool { + return ret[i].OpenAt > ret[j].OpenAt + }) + } + case "viewedAt": // 按浏览时间排序 + default: + filtered := []*RecentDoc{} // 初始化为空切片,确保 API 始终返回非 nil for _, doc := range ret { if doc.ViewedAt > 0 { filtered = append(filtered, doc) } } ret = filtered - sort.Slice(ret, func(i, j int) bool { - return ret[i].ViewedAt > ret[j].ViewedAt - }) + if 0 < len(ret) { + sort.Slice(ret, func(i, j int) bool { + return ret[i].ViewedAt > ret[j].ViewedAt + }) + } } return }