A privacy-first, self-hosted, fully open source personal knowledge management software, written in typescript and golang. (PERSONAL FORK)
1// SiYuan - Refactor your thinking
2// Copyright (c) 2020-present, b3log.org
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU Affero General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU Affero General Public License for more details.
13//
14// You should have received a copy of the GNU Affero General Public License
15// along with this program. If not, see <https://www.gnu.org/licenses/>.
16
17package model
18
19import (
20 "errors"
21 "os"
22 "path"
23 "path/filepath"
24 "strings"
25 "time"
26
27 "github.com/88250/gulu"
28 "github.com/88250/lute/ast"
29 "github.com/88250/lute/parse"
30 "github.com/siyuan-note/logging"
31 "github.com/siyuan-note/siyuan/kernel/cache"
32 "github.com/siyuan-note/siyuan/kernel/sql"
33 "github.com/siyuan-note/siyuan/kernel/treenode"
34 "github.com/siyuan-note/siyuan/kernel/util"
35)
36
37func (tx *Transaction) doFoldHeading(operation *Operation) (ret *TxErr) {
38 headingID := operation.ID
39 tree, err := tx.loadTree(headingID)
40 if err != nil {
41 return &TxErr{code: TxErrCodeBlockNotFound, id: headingID}
42 }
43
44 childrenIDs := []string{} // 这里不能用 nil,否则折叠下方没内容的标题时会内核中断 https://github.com/siyuan-note/siyuan/issues/3643
45 heading := treenode.GetNodeInTree(tree, headingID)
46 if nil == heading {
47 return &TxErr{code: TxErrCodeBlockNotFound, id: headingID}
48 }
49
50 children := treenode.HeadingChildren(heading)
51 for _, child := range children {
52 childrenIDs = append(childrenIDs, child.ID)
53 ast.Walk(child, func(n *ast.Node, entering bool) ast.WalkStatus {
54 if !entering || !n.IsBlock() {
55 return ast.WalkContinue
56 }
57
58 n.SetIALAttr("fold", "1")
59 n.SetIALAttr("heading-fold", "1")
60 return ast.WalkContinue
61 })
62 }
63 heading.SetIALAttr("fold", "1")
64 if err = tx.writeTree(tree); err != nil {
65 return &TxErr{code: TxErrCodeWriteTree, msg: err.Error(), id: headingID}
66 }
67 IncSync()
68
69 cache.PutBlockIAL(headingID, parse.IAL2Map(heading.KramdownIAL))
70 for _, child := range children {
71 cache.PutBlockIAL(child.ID, parse.IAL2Map(child.KramdownIAL))
72 }
73 sql.UpsertTreeQueue(tree)
74 operation.RetData = childrenIDs
75 return
76}
77
78func (tx *Transaction) doUnfoldHeading(operation *Operation) (ret *TxErr) {
79 headingID := operation.ID
80
81 tree, err := tx.loadTree(headingID)
82 if err != nil {
83 return &TxErr{code: TxErrCodeBlockNotFound, id: headingID}
84 }
85
86 heading := treenode.GetNodeInTree(tree, headingID)
87 if nil == heading {
88 return &TxErr{code: TxErrCodeBlockNotFound, id: headingID}
89 }
90
91 luteEngine := NewLute()
92 parentFoldedHeading := treenode.GetParentFoldedHeading(heading)
93 if nil != parentFoldedHeading {
94 // 如果当前标题在上方某个折叠的标题下方,则展开上方那个折叠标题以保持一致性
95 children := treenode.HeadingChildren(parentFoldedHeading)
96 for _, child := range children {
97 ast.Walk(child, func(n *ast.Node, entering bool) ast.WalkStatus {
98 if !entering || !n.IsBlock() {
99 return ast.WalkContinue
100 }
101
102 n.RemoveIALAttr("heading-fold")
103 n.RemoveIALAttr("fold")
104 return ast.WalkContinue
105 })
106 }
107 parentFoldedHeading.RemoveIALAttr("fold")
108 parentFoldedHeading.RemoveIALAttr("heading-fold")
109 go func() {
110 tx.WaitForCommit()
111 ReloadProtyle(tree.ID)
112 }()
113 }
114
115 children := treenode.HeadingChildren(heading)
116 for _, child := range children {
117 ast.Walk(child, func(n *ast.Node, entering bool) ast.WalkStatus {
118 if !entering {
119 return ast.WalkContinue
120 }
121
122 n.RemoveIALAttr("heading-fold")
123 n.RemoveIALAttr("fold")
124 return ast.WalkContinue
125 })
126 }
127 heading.RemoveIALAttr("fold")
128 heading.RemoveIALAttr("heading-fold")
129 if err = tx.writeTree(tree); err != nil {
130 return &TxErr{code: TxErrCodeWriteTree, msg: err.Error(), id: headingID}
131 }
132 IncSync()
133
134 cache.PutBlockIAL(headingID, parse.IAL2Map(heading.KramdownIAL))
135 for _, child := range children {
136 cache.PutBlockIAL(child.ID, parse.IAL2Map(child.KramdownIAL))
137 }
138 sql.UpsertTreeQueue(tree)
139
140 // 展开折叠的标题后显示块引用计数 Display reference counts after unfolding headings https://github.com/siyuan-note/siyuan/issues/13618
141 fillBlockRefCount(children)
142
143 operation.RetData = renderBlockDOMByNodes(children, luteEngine)
144 return
145}
146
147func Doc2Heading(srcID, targetID string, after bool) (srcTreeBox, srcTreePath string, err error) {
148 if !ast.IsNodeIDPattern(srcID) || !ast.IsNodeIDPattern(targetID) {
149 return
150 }
151
152 FlushTxQueue()
153
154 srcTree, _ := LoadTreeByBlockID(srcID)
155 if nil == srcTree {
156 err = ErrBlockNotFound
157 return
158 }
159
160 subDir := filepath.Join(util.DataDir, srcTree.Box, strings.TrimSuffix(srcTree.Path, ".sy"))
161 if gulu.File.IsDir(subDir) {
162 if !util.IsEmptyDir(subDir) {
163 err = errors.New(Conf.Language(20))
164 return
165 }
166
167 if removeErr := os.Remove(subDir); nil != removeErr { // 移除空文件夹不会有副作用
168 logging.LogWarnf("remove empty dir [%s] failed: %s", subDir, removeErr)
169 }
170 }
171
172 if nil == treenode.GetBlockTree(targetID) {
173 // 目标块不存在时忽略处理
174 return
175 }
176
177 targetTree, _ := LoadTreeByBlockID(targetID)
178 if nil == targetTree {
179 // 目标块不存在时忽略处理
180 return
181 }
182
183 pivot := treenode.GetNodeInTree(targetTree, targetID)
184 if nil == pivot {
185 err = ErrBlockNotFound
186 return
187 }
188
189 // 生成文档历史 https://github.com/siyuan-note/siyuan/issues/14359
190 generateOpTypeHistory(srcTree, HistoryOpUpdate)
191
192 // 移动前先删除引用 https://github.com/siyuan-note/siyuan/issues/7819
193 sql.DeleteRefsTreeQueue(srcTree)
194 sql.DeleteRefsTreeQueue(targetTree)
195
196 if ast.NodeListItem == pivot.Type {
197 pivot = pivot.LastChild
198 }
199
200 pivotLevel := treenode.HeadingLevel(pivot)
201 deltaLevel := pivotLevel - treenode.TopHeadingLevel(srcTree) + 1
202 headingLevel := pivotLevel
203 if ast.NodeHeading == pivot.Type { // 平级插入
204 children := treenode.HeadingChildren(pivot)
205 if after {
206 if length := len(children); 0 < length {
207 pivot = children[length-1]
208 }
209 }
210 } else { // 子节点插入
211 headingLevel++
212 deltaLevel++
213 }
214 if 6 < headingLevel {
215 headingLevel = 6
216 }
217
218 srcTree.Root.RemoveIALAttr("scroll") // Remove `scroll` attribute when converting the document to a heading https://github.com/siyuan-note/siyuan/issues/9297
219 srcTree.Root.RemoveIALAttr("type")
220 tagIAL := srcTree.Root.IALAttr("tags")
221 tags := strings.Split(tagIAL, ",")
222 srcTree.Root.RemoveIALAttr("tags")
223 heading := &ast.Node{ID: srcTree.Root.ID, Type: ast.NodeHeading, HeadingLevel: headingLevel, KramdownIAL: srcTree.Root.KramdownIAL}
224 heading.SetIALAttr("updated", util.CurrentTimeSecondsStr())
225 heading.AppendChild(&ast.Node{Type: ast.NodeText, Tokens: []byte(srcTree.Root.IALAttr("title"))})
226 heading.RemoveIALAttr("title")
227 heading.Box, heading.Path = targetTree.Box, targetTree.Path
228 if "" != tagIAL && 0 < len(tags) {
229 // 带标签的文档块转换为标题块时将标签移动到标题块下方 https://github.com/siyuan-note/siyuan/issues/6550
230
231 tagPara := treenode.NewParagraph("")
232 for i, tag := range tags {
233 if "" == tag {
234 continue
235 }
236
237 tagPara.AppendChild(&ast.Node{Type: ast.NodeTextMark, TextMarkType: "tag", TextMarkTextContent: tag})
238 if i < len(tags)-1 {
239 tagPara.AppendChild(&ast.Node{Type: ast.NodeText, Tokens: []byte(" ")})
240 }
241 }
242 if nil != tagPara.FirstChild {
243 srcTree.Root.PrependChild(tagPara)
244 }
245 }
246
247 var nodes []*ast.Node
248 if after {
249 for c := srcTree.Root.LastChild; nil != c; c = c.Previous {
250 nodes = append(nodes, c)
251 }
252 } else {
253 for c := srcTree.Root.FirstChild; nil != c; c = c.Next {
254 nodes = append(nodes, c)
255 }
256 }
257
258 if !after {
259 pivot.InsertBefore(heading)
260 }
261
262 for _, n := range nodes {
263 if ast.NodeHeading == n.Type {
264 n.HeadingLevel = n.HeadingLevel + deltaLevel
265 if 6 < n.HeadingLevel {
266 n.HeadingLevel = 6
267 }
268 }
269 n.Box = targetTree.Box
270 n.Path = targetTree.Path
271 if after {
272 pivot.InsertAfter(n)
273 } else {
274 pivot.InsertBefore(n)
275 }
276 }
277
278 if after {
279 pivot.InsertAfter(heading)
280 }
281
282 box := Conf.Box(srcTree.Box)
283 if removeErr := box.Remove(srcTree.Path); nil != removeErr {
284 logging.LogWarnf("remove tree [%s] failed: %s", srcTree.Path, removeErr)
285 }
286 box.removeSort([]string{srcTree.ID})
287 RemoveRecentDoc([]string{srcTree.ID})
288 evt := util.NewCmdResult("removeDoc", 0, util.PushModeBroadcast)
289 evt.Data = map[string]interface{}{
290 "ids": []string{srcTree.ID},
291 }
292 util.PushEvent(evt)
293
294 srcTreeBox, srcTreePath = srcTree.Box, srcTree.Path // 返回旧的文档块位置,前端后续会删除旧的文档块
295 targetTree.Root.SetIALAttr("updated", util.CurrentTimeSecondsStr())
296 treenode.RemoveBlockTreesByRootID(srcTree.ID)
297 treenode.RemoveBlockTreesByRootID(targetTree.ID)
298 err = indexWriteTreeUpsertQueue(targetTree)
299 IncSync()
300 go func() {
301 time.Sleep(util.SQLFlushInterval)
302 RefreshBacklink(srcTree.ID)
303 RefreshBacklink(targetTree.ID)
304 ResetVirtualBlockRefCache()
305 }()
306 return
307}
308
309func Heading2Doc(srcHeadingID, targetBoxID, targetPath, previousPath string) (srcRootBlockID, newTargetPath string, err error) {
310 FlushTxQueue()
311
312 srcTree, _ := LoadTreeByBlockID(srcHeadingID)
313 if nil == srcTree {
314 err = ErrBlockNotFound
315 return
316 }
317 srcRootBlockID = srcTree.Root.ID
318
319 headingBlock, err := getBlock(srcHeadingID, srcTree)
320 if err != nil {
321 return
322 }
323 if nil == headingBlock {
324 err = ErrBlockNotFound
325 return
326 }
327 headingNode := treenode.GetNodeInTree(srcTree, srcHeadingID)
328 if nil == headingNode {
329 err = ErrBlockNotFound
330 return
331 }
332
333 box := Conf.Box(targetBoxID)
334 headingText := getNodeRefText0(headingNode, Conf.Editor.BlockRefDynamicAnchorTextMaxLen, true)
335 if strings.Contains(headingText, "/") {
336 headingText = strings.ReplaceAll(headingText, "/", "_")
337 util.PushMsg(Conf.language(246), 7000)
338 }
339
340 moveToRoot := "/" == targetPath
341 toHP := path.Join("/", headingText)
342 toFolder := "/"
343 if "" != previousPath {
344 previousDoc := treenode.GetBlockTreeRootByPath(targetBoxID, previousPath)
345 if nil == previousDoc {
346 err = ErrBlockNotFound
347 return
348 }
349 parentPath := path.Dir(previousPath)
350 if "/" != parentPath {
351 parentPath = strings.TrimSuffix(parentPath, "/") + ".sy"
352 parentDoc := treenode.GetBlockTreeRootByPath(targetBoxID, parentPath)
353 if nil == parentDoc {
354 err = ErrBlockNotFound
355 return
356 }
357 toHP = path.Join(parentDoc.HPath, headingText)
358 toFolder = path.Join(path.Dir(parentPath), parentDoc.ID)
359 }
360 } else {
361 if !moveToRoot {
362 parentDoc := treenode.GetBlockTreeRootByPath(targetBoxID, targetPath)
363 if nil == parentDoc {
364 err = ErrBlockNotFound
365 return
366 }
367 toHP = path.Join(parentDoc.HPath, headingText)
368 toFolder = path.Join(path.Dir(targetPath), parentDoc.ID)
369 }
370 }
371
372 newTargetPath = path.Join(toFolder, srcHeadingID+".sy")
373 if !box.Exist(toFolder) {
374 if err = box.MkdirAll(toFolder); err != nil {
375 return
376 }
377 }
378
379 // 折叠标题转换为文档时需要自动展开下方块 https://github.com/siyuan-note/siyuan/issues/2947
380 children := treenode.HeadingChildren(headingNode)
381 for _, child := range children {
382 ast.Walk(child, func(n *ast.Node, entering bool) ast.WalkStatus {
383 if !entering {
384 return ast.WalkContinue
385 }
386
387 n.RemoveIALAttr("heading-fold")
388 n.RemoveIALAttr("fold")
389 return ast.WalkContinue
390 })
391 }
392 headingNode.RemoveIALAttr("fold")
393 headingNode.RemoveIALAttr("heading-fold")
394
395 luteEngine := util.NewLute()
396 newTree := &parse.Tree{Root: &ast.Node{Type: ast.NodeDocument, ID: srcHeadingID}, Context: &parse.Context{ParseOption: luteEngine.ParseOptions}}
397 for _, c := range children {
398 newTree.Root.AppendChild(c)
399 }
400 newTree.ID = srcHeadingID
401 newTree.Path = newTargetPath
402 newTree.HPath = toHP
403 headingNode.SetIALAttr("type", "doc")
404 headingNode.SetIALAttr("id", srcHeadingID)
405 headingNode.SetIALAttr("title", headingText)
406 newTree.Root.KramdownIAL = headingNode.KramdownIAL
407
408 topLevel := treenode.TopHeadingLevel(newTree)
409 for c := newTree.Root.FirstChild; nil != c; c = c.Next {
410 if ast.NodeHeading == c.Type {
411 c.HeadingLevel = c.HeadingLevel - topLevel + 2
412 if 6 < c.HeadingLevel {
413 c.HeadingLevel = 6
414 }
415 }
416 }
417
418 headingNode.Unlink()
419 srcTree.Root.SetIALAttr("updated", util.CurrentTimeSecondsStr())
420 if nil == srcTree.Root.FirstChild {
421 srcTree.Root.AppendChild(treenode.NewParagraph(""))
422 }
423 treenode.RemoveBlockTreesByRootID(srcTree.ID)
424 if err = indexWriteTreeUpsertQueue(srcTree); err != nil {
425 return "", "", err
426 }
427
428 newTree.Box, newTree.Path = targetBoxID, newTargetPath
429 newTree.Root.SetIALAttr("updated", util.CurrentTimeSecondsStr())
430 newTree.Root.Spec = "1"
431 if "" != previousPath {
432 box.addSort(previousPath, newTree.ID)
433 } else {
434 box.addMinSort(path.Dir(newTargetPath), newTree.ID)
435 }
436 if err = indexWriteTreeUpsertQueue(newTree); err != nil {
437 return "", "", err
438 }
439 IncSync()
440 go func() {
441 RefreshBacklink(srcTree.ID)
442 RefreshBacklink(newTree.ID)
443 ResetVirtualBlockRefCache()
444 }()
445 return
446}