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 = '';
+ let docIndex = 0;
if (!key || window.siyuan.languages.riffCard.toLowerCase().includes(key.toLowerCase())) {
dockHtml += `-
@@ -57,30 +52,30 @@ ${unicode2Emoji(item.icon || window.siyuan.storage[Constants.LOCAL_IMAGES].file,
if (!switchPath) {
switchPath = window.siyuan.languages.riffCard;
}
+ docIndex++;
}
- let docIndex = 1;
getAllDocks().forEach((item) => {
if (!key || item.title.toLowerCase().includes(key.toLowerCase())) {
dockHtml += `
-
${item.title}
- ${updateHotkeyTip(item.hotkey || "")}
+ ${updateHotkeyTip(item.hotkey)}
`;
- docIndex++;
if (!switchPath) {
- switchPath = window.siyuan.languages.riffCard;
+ switchPath = item.title;
}
+ docIndex++;
}
});
- dockHtml = dockHtml + "
";
+ dockHtml = '";
}
const pathElement = element.querySelector(".switch-doc__path");
pathElement.innerHTML = switchPath;
- pathElement.previousElementSibling.innerHTML = ``;
+ pathElement.previousElementSibling.innerHTML = ``;
};
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
}