assets.go 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972
  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. package model
  17. import (
  18. "bytes"
  19. "errors"
  20. "fmt"
  21. "io"
  22. "io/fs"
  23. "mime"
  24. "net/http"
  25. "net/url"
  26. "os"
  27. "path"
  28. "path/filepath"
  29. "sort"
  30. "strings"
  31. "github.com/88250/gulu"
  32. "github.com/88250/lute/ast"
  33. "github.com/88250/lute/html"
  34. "github.com/88250/lute/parse"
  35. "github.com/dustin/go-humanize"
  36. "github.com/gabriel-vasile/mimetype"
  37. "github.com/siyuan-note/filelock"
  38. "github.com/siyuan-note/httpclient"
  39. "github.com/siyuan-note/logging"
  40. "github.com/siyuan-note/siyuan/kernel/cache"
  41. "github.com/siyuan-note/siyuan/kernel/filesys"
  42. "github.com/siyuan-note/siyuan/kernel/search"
  43. "github.com/siyuan-note/siyuan/kernel/sql"
  44. "github.com/siyuan-note/siyuan/kernel/treenode"
  45. "github.com/siyuan-note/siyuan/kernel/util"
  46. )
  47. func DocImageAssets(rootID string) (ret []string, err error) {
  48. tree, err := loadTreeByBlockID(rootID)
  49. if nil != err {
  50. return
  51. }
  52. ast.Walk(tree.Root, func(n *ast.Node, entering bool) ast.WalkStatus {
  53. if !entering {
  54. return ast.WalkContinue
  55. }
  56. if ast.NodeImage == n.Type {
  57. linkDest := n.ChildByType(ast.NodeLinkDest)
  58. dest := linkDest.Tokens
  59. if 1 > len(dest) { // 双击打开图片不对 https://github.com/siyuan-note/siyuan/issues/5876
  60. return ast.WalkContinue
  61. }
  62. ret = append(ret, gulu.Str.FromBytes(dest))
  63. }
  64. return ast.WalkContinue
  65. })
  66. return
  67. }
  68. func NetImg2LocalAssets(rootID, originalURL string) (err error) {
  69. tree, err := loadTreeByBlockID(rootID)
  70. if nil != err {
  71. return
  72. }
  73. var files int
  74. msgId := gulu.Rand.String(7)
  75. docDirLocalPath := filepath.Join(util.DataDir, tree.Box, path.Dir(tree.Path))
  76. assetsDirPath := getAssetsDir(filepath.Join(util.DataDir, tree.Box), docDirLocalPath)
  77. if !gulu.File.IsExist(assetsDirPath) {
  78. if err = os.MkdirAll(assetsDirPath, 0755); nil != err {
  79. return
  80. }
  81. }
  82. ast.Walk(tree.Root, func(n *ast.Node, entering bool) ast.WalkStatus {
  83. if !entering {
  84. return ast.WalkContinue
  85. }
  86. if ast.NodeImage == n.Type {
  87. linkDest := n.ChildByType(ast.NodeLinkDest)
  88. dest := linkDest.Tokens
  89. if util.IsAssetLinkDest(dest) {
  90. return ast.WalkSkipChildren
  91. }
  92. if bytes.HasPrefix(bytes.ToLower(dest), []byte("file://")) {
  93. // `网络图片转换为本地图片` 支持处理 `file://` 本地路径图片 https://github.com/siyuan-note/siyuan/issues/6546
  94. u := string(dest)[7:]
  95. if !gulu.File.IsExist(u) || gulu.File.IsDir(u) {
  96. return ast.WalkSkipChildren
  97. }
  98. name := filepath.Base(u)
  99. name = util.FilterFileName(name)
  100. name = util.TruncateLenFileName(name)
  101. name = "net-img-" + name
  102. name = util.AssetName(name)
  103. writePath := filepath.Join(assetsDirPath, name)
  104. if err = filelock.Copy(u, writePath); nil != err {
  105. logging.LogErrorf("copy [%s] to [%s] failed: %s", u, writePath, err)
  106. return ast.WalkSkipChildren
  107. }
  108. linkDest.Tokens = []byte("assets/" + name)
  109. files++
  110. return ast.WalkSkipChildren
  111. }
  112. if bytes.HasPrefix(bytes.ToLower(dest), []byte("https://")) || bytes.HasPrefix(bytes.ToLower(dest), []byte("http://")) {
  113. u := string(dest)
  114. if strings.Contains(u, "qpic.cn") {
  115. // 改进 `网络图片转换为本地图片` 微信图片拉取 https://github.com/siyuan-note/siyuan/issues/5052
  116. if strings.Contains(u, "http://") {
  117. u = strings.Replace(u, "http://", "https://", 1)
  118. }
  119. // 改进 `网络图片转换为本地图片` 微信图片拉取 https://github.com/siyuan-note/siyuan/issues/6431
  120. // 下面这部分需要注释掉,否则会导致响应 400
  121. //if strings.HasSuffix(u, "/0") {
  122. // u = strings.Replace(u, "/0", "/640", 1)
  123. //} else if strings.Contains(u, "/0?") {
  124. // u = strings.Replace(u, "/0?", "/640?", 1)
  125. //}
  126. }
  127. util.PushUpdateMsg(msgId, fmt.Sprintf(Conf.Language(119), u), 15000)
  128. request := httpclient.NewBrowserRequest()
  129. if "" != originalURL {
  130. request.SetHeader("Referer", originalURL) // 改进浏览器剪藏扩展转换本地图片成功率 https://github.com/siyuan-note/siyuan/issues/7464
  131. }
  132. resp, reqErr := request.Get(u)
  133. if nil != reqErr {
  134. logging.LogErrorf("download net img [%s] failed: %s", u, reqErr)
  135. return ast.WalkSkipChildren
  136. }
  137. if 200 != resp.StatusCode {
  138. logging.LogErrorf("download net img [%s] failed: %d", u, resp.StatusCode)
  139. return ast.WalkSkipChildren
  140. }
  141. data, repErr := resp.ToBytes()
  142. if nil != repErr {
  143. logging.LogErrorf("download net img [%s] failed: %s", u, repErr)
  144. return ast.WalkSkipChildren
  145. }
  146. var name string
  147. if strings.Contains(u, "?") {
  148. name = u[:strings.Index(u, "?")]
  149. name = path.Base(name)
  150. } else {
  151. name = path.Base(u)
  152. }
  153. if strings.Contains(name, "#") {
  154. name = name[:strings.Index(name, "#")]
  155. }
  156. name, _ = url.PathUnescape(name)
  157. ext := path.Ext(name)
  158. if "" == ext {
  159. if mtype := mimetype.Detect(data); nil != mtype {
  160. ext = mtype.Extension()
  161. }
  162. }
  163. if "" == ext {
  164. contentType := resp.Header.Get("Content-Type")
  165. exts, _ := mime.ExtensionsByType(contentType)
  166. if 0 < len(exts) {
  167. ext = exts[0]
  168. }
  169. }
  170. name = strings.TrimSuffix(name, ext)
  171. name = util.FilterFileName(name)
  172. name = util.TruncateLenFileName(name)
  173. name = "net-img-" + name + "-" + ast.NewNodeID() + ext
  174. writePath := filepath.Join(assetsDirPath, name)
  175. if err = filelock.WriteFile(writePath, data); nil != err {
  176. logging.LogErrorf("write downloaded net img [%s] to local assets [%s] failed: %s", u, writePath, err)
  177. return ast.WalkSkipChildren
  178. }
  179. linkDest.Tokens = []byte("assets/" + name)
  180. files++
  181. }
  182. return ast.WalkSkipChildren
  183. }
  184. return ast.WalkContinue
  185. })
  186. if 0 < files {
  187. util.PushUpdateMsg(msgId, Conf.Language(113), 7000)
  188. if err = writeJSONQueue(tree); nil != err {
  189. return
  190. }
  191. util.PushUpdateMsg(msgId, fmt.Sprintf(Conf.Language(120), files), 5000)
  192. } else {
  193. util.PushUpdateMsg(msgId, Conf.Language(121), 3000)
  194. }
  195. return
  196. }
  197. func SearchAssetsByName(keyword string) (ret []*cache.Asset) {
  198. ret = []*cache.Asset{}
  199. count := 0
  200. for _, asset := range cache.GetAssets() {
  201. if !strings.Contains(strings.ToLower(asset.HName), strings.ToLower(keyword)) {
  202. continue
  203. }
  204. _, hName := search.MarkText(asset.HName, keyword, 64, Conf.Search.CaseSensitive)
  205. ret = append(ret, &cache.Asset{
  206. HName: hName,
  207. Path: asset.Path,
  208. Updated: asset.Updated,
  209. })
  210. count++
  211. if Conf.Search.Limit <= count {
  212. return
  213. }
  214. }
  215. sort.Slice(ret, func(i, j int) bool {
  216. return ret[i].Updated > ret[j].Updated
  217. })
  218. return
  219. }
  220. func GetAssetAbsPath(relativePath string) (absPath string, err error) {
  221. relativePath = strings.TrimSpace(relativePath)
  222. if strings.Contains(relativePath, "?") {
  223. relativePath = relativePath[:strings.Index(relativePath, "?")]
  224. }
  225. notebooks, err := ListNotebooks()
  226. if nil != err {
  227. err = errors.New(Conf.Language(0))
  228. return
  229. }
  230. // 在笔记本下搜索
  231. for _, notebook := range notebooks {
  232. notebookAbsPath := filepath.Join(util.DataDir, notebook.ID)
  233. filepath.Walk(notebookAbsPath, func(path string, info fs.FileInfo, _ error) error {
  234. if isSkipFile(info.Name()) {
  235. if info.IsDir() {
  236. return filepath.SkipDir
  237. }
  238. return nil
  239. }
  240. if p := filepath.ToSlash(path); strings.HasSuffix(p, relativePath) {
  241. if gulu.File.IsExist(path) {
  242. absPath = path
  243. return io.EOF
  244. }
  245. }
  246. return nil
  247. })
  248. if "" != absPath {
  249. return
  250. }
  251. }
  252. // 在全局 assets 路径下搜索
  253. p := filepath.Join(util.DataDir, relativePath)
  254. if gulu.File.IsExist(p) {
  255. absPath = p
  256. return
  257. }
  258. return "", errors.New(fmt.Sprintf(Conf.Language(12), relativePath))
  259. }
  260. func UploadAssets2Cloud(rootID string) (err error) {
  261. if !IsSubscriber() {
  262. return
  263. }
  264. sqlAssets := sql.QueryRootBlockAssets(rootID)
  265. err = uploadAssets2Cloud(sqlAssets, bizTypeUploadAssets)
  266. return
  267. }
  268. const (
  269. bizTypeUploadAssets = "upload-assets"
  270. bizTypeExport2Liandi = "export-liandi"
  271. )
  272. // uploadAssets2Cloud 将资源文件上传到云端图床。
  273. func uploadAssets2Cloud(sqlAssets []*sql.Asset, bizType string) (err error) {
  274. var uploadAbsAssets []string
  275. for _, asset := range sqlAssets {
  276. var absPath string
  277. absPath, err = GetAssetAbsPath(asset.Path)
  278. if nil != err {
  279. logging.LogWarnf("get asset [%s] abs path failed: %s", asset, err)
  280. return
  281. }
  282. if "" == absPath {
  283. logging.LogErrorf("not found asset [%s]", asset)
  284. continue
  285. }
  286. uploadAbsAssets = append(uploadAbsAssets, absPath)
  287. }
  288. uploadAbsAssets = gulu.Str.RemoveDuplicatedElem(uploadAbsAssets)
  289. if 1 > len(uploadAbsAssets) {
  290. return
  291. }
  292. logging.LogInfof("uploading [%d] assets", len(uploadAbsAssets))
  293. msgId := util.PushMsg(fmt.Sprintf(Conf.Language(27), len(uploadAbsAssets)), 3000)
  294. if loadErr := LoadUploadToken(); nil != loadErr {
  295. util.PushMsg(loadErr.Error(), 5000)
  296. return
  297. }
  298. limitSize := uint64(3 * 1024 * 1024) // 3MB
  299. if IsSubscriber() {
  300. limitSize = 10 * 1024 * 1024 // 10MB
  301. }
  302. // metaType 为服务端 Filemeta.FILEMETA_TYPE,这里只有两个值:
  303. //
  304. // 5: SiYuan,表示为 SiYuan 上传图床
  305. // 4: Client,表示作为客户端分享发布帖子时上传的文件
  306. var metaType = "5"
  307. if bizTypeUploadAssets == bizType {
  308. metaType = "5"
  309. } else if bizTypeExport2Liandi == bizType {
  310. metaType = "4"
  311. }
  312. var completedUploadAssets []string
  313. for _, absAsset := range uploadAbsAssets {
  314. fi, statErr := os.Stat(absAsset)
  315. if nil != statErr {
  316. logging.LogErrorf("stat file [%s] failed: %s", absAsset, statErr)
  317. return statErr
  318. }
  319. if limitSize < uint64(fi.Size()) {
  320. logging.LogWarnf("file [%s] larger than limit size [%s], ignore uploading it", humanize.IBytes(limitSize), absAsset)
  321. continue
  322. }
  323. msg := fmt.Sprintf(Conf.Language(27), html.EscapeString(absAsset))
  324. util.PushStatusBar(msg)
  325. util.PushUpdateMsg(msgId, msg, 3000)
  326. requestResult := gulu.Ret.NewResult()
  327. request := httpclient.NewCloudFileRequest2m()
  328. resp, reqErr := request.
  329. SetSuccessResult(requestResult).
  330. SetFile("file[]", absAsset).
  331. SetCookies(&http.Cookie{Name: "symphony", Value: uploadToken}).
  332. SetHeader("meta-type", metaType).
  333. SetHeader("biz-type", bizType).
  334. Post(util.GetCloudServer() + "/apis/siyuan/upload?ver=" + util.Ver)
  335. if nil != reqErr {
  336. logging.LogErrorf("upload assets failed: %s", reqErr)
  337. return ErrFailedToConnectCloudServer
  338. }
  339. if 401 == resp.StatusCode {
  340. err = errors.New(Conf.Language(31))
  341. return
  342. }
  343. if 0 != requestResult.Code {
  344. logging.LogErrorf("upload assets failed: %s", requestResult.Msg)
  345. err = errors.New(fmt.Sprintf(Conf.Language(94), requestResult.Msg))
  346. return
  347. }
  348. absAsset = filepath.ToSlash(absAsset)
  349. relAsset := absAsset[strings.Index(absAsset, "assets/"):]
  350. completedUploadAssets = append(completedUploadAssets, relAsset)
  351. logging.LogInfof("uploaded asset [%s]", relAsset)
  352. }
  353. util.PushClearMsg(msgId)
  354. if 0 < len(completedUploadAssets) {
  355. logging.LogInfof("uploaded [%d] assets", len(completedUploadAssets))
  356. }
  357. return
  358. }
  359. func RemoveUnusedAssets() (ret []string) {
  360. msgId := util.PushMsg(Conf.Language(100), 30*1000)
  361. defer func() {
  362. util.PushClearMsg(msgId)
  363. util.PushMsg(Conf.Language(99), 3000)
  364. }()
  365. ret = []string{}
  366. unusedAssets := UnusedAssets()
  367. historyDir, err := GetHistoryDir(HistoryOpClean)
  368. if nil != err {
  369. logging.LogErrorf("get history dir failed: %s", err)
  370. return
  371. }
  372. var hashes []string
  373. for _, p := range unusedAssets {
  374. historyPath := filepath.Join(historyDir, p)
  375. if p = filepath.Join(util.DataDir, p); gulu.File.IsExist(p) {
  376. if err = filelock.Copy(p, historyPath); nil != err {
  377. return
  378. }
  379. hash, _ := util.GetEtag(p)
  380. hashes = append(hashes, hash)
  381. }
  382. }
  383. sql.BatchRemoveAssetsQueue(hashes)
  384. for _, unusedAsset := range unusedAssets {
  385. if unusedAsset = filepath.Join(util.DataDir, unusedAsset); gulu.File.IsExist(unusedAsset) {
  386. if err := os.RemoveAll(unusedAsset); nil != err {
  387. logging.LogErrorf("remove unused asset [%s] failed: %s", unusedAsset, err)
  388. }
  389. }
  390. ret = append(ret, unusedAsset)
  391. }
  392. if 0 < len(ret) {
  393. IncSync()
  394. }
  395. indexHistoryDir(filepath.Base(historyDir), util.NewLute())
  396. cache.LoadAssets()
  397. return
  398. }
  399. func RemoveUnusedAsset(p string) (ret string) {
  400. absPath := filepath.Join(util.DataDir, p)
  401. if !gulu.File.IsExist(absPath) {
  402. return absPath
  403. }
  404. historyDir, err := GetHistoryDir(HistoryOpClean)
  405. if nil != err {
  406. logging.LogErrorf("get history dir failed: %s", err)
  407. return
  408. }
  409. newP := strings.TrimPrefix(absPath, util.DataDir)
  410. historyPath := filepath.Join(historyDir, newP)
  411. if gulu.File.IsExist(absPath) {
  412. if err = filelock.Copy(absPath, historyPath); nil != err {
  413. return
  414. }
  415. hash, _ := util.GetEtag(absPath)
  416. sql.BatchRemoveAssetsQueue([]string{hash})
  417. }
  418. if err = os.RemoveAll(absPath); nil != err {
  419. logging.LogErrorf("remove unused asset [%s] failed: %s", absPath, err)
  420. }
  421. ret = absPath
  422. IncSync()
  423. indexHistoryDir(filepath.Base(historyDir), util.NewLute())
  424. cache.RemoveAsset(p)
  425. return
  426. }
  427. func RenameAsset(oldPath, newName string) (err error) {
  428. util.PushEndlessProgress(Conf.Language(110))
  429. defer util.PushClearProgress()
  430. newName = strings.TrimSpace(newName)
  431. newName = gulu.Str.RemoveInvisible(newName)
  432. if path.Base(oldPath) == newName {
  433. return
  434. }
  435. if "" == newName {
  436. return
  437. }
  438. if !gulu.File.IsValidFilename(newName) {
  439. err = errors.New(Conf.Language(151))
  440. return
  441. }
  442. newName = util.AssetName(newName + filepath.Ext(oldPath))
  443. newPath := "assets/" + newName
  444. if err = filelock.Copy(filepath.Join(util.DataDir, oldPath), filepath.Join(util.DataDir, newPath)); nil != err {
  445. logging.LogErrorf("copy asset [%s] failed: %s", oldPath, err)
  446. return
  447. }
  448. oldName := path.Base(oldPath)
  449. notebooks, err := ListNotebooks()
  450. if nil != err {
  451. return
  452. }
  453. luteEngine := util.NewLute()
  454. for _, notebook := range notebooks {
  455. pages := pagedPaths(filepath.Join(util.DataDir, notebook.ID), 32)
  456. for _, paths := range pages {
  457. for _, treeAbsPath := range paths {
  458. data, readErr := filelock.ReadFile(treeAbsPath)
  459. if nil != readErr {
  460. logging.LogErrorf("get data [path=%s] failed: %s", treeAbsPath, readErr)
  461. err = readErr
  462. return
  463. }
  464. util.PushEndlessProgress(fmt.Sprintf(Conf.Language(70), filepath.Base(treeAbsPath)))
  465. if !bytes.Contains(data, []byte(oldName)) {
  466. continue
  467. }
  468. data = bytes.Replace(data, []byte(oldName), []byte(newName), -1)
  469. if writeErr := filelock.WriteFile(treeAbsPath, data); nil != writeErr {
  470. logging.LogErrorf("write data [path=%s] failed: %s", treeAbsPath, writeErr)
  471. err = writeErr
  472. return
  473. }
  474. p := filepath.ToSlash(strings.TrimPrefix(treeAbsPath, filepath.Join(util.DataDir, notebook.ID)))
  475. tree, parseErr := filesys.LoadTreeByData(data, notebook.ID, p, luteEngine)
  476. if nil != parseErr {
  477. logging.LogWarnf("parse json to tree [%s] failed: %s", treeAbsPath, parseErr)
  478. continue
  479. }
  480. treenode.IndexBlockTree(tree)
  481. sql.UpsertTreeQueue(tree)
  482. util.PushEndlessProgress(fmt.Sprintf(Conf.Language(111), util.EscapeHTML(tree.Root.IALAttr("title"))))
  483. }
  484. }
  485. }
  486. IncSync()
  487. util.ReloadUI()
  488. return
  489. }
  490. func UnusedAssets() (ret []string) {
  491. defer logging.Recover()
  492. ret = []string{}
  493. assetsPathMap, err := allAssetAbsPaths()
  494. if nil != err {
  495. return
  496. }
  497. linkDestMap := map[string]bool{}
  498. notebooks, err := ListNotebooks()
  499. if nil != err {
  500. return
  501. }
  502. luteEngine := util.NewLute()
  503. for _, notebook := range notebooks {
  504. dests := map[string]bool{}
  505. // 分页加载,优化清理未引用资源内存占用 https://github.com/siyuan-note/siyuan/issues/5200
  506. pages := pagedPaths(filepath.Join(util.DataDir, notebook.ID), 32)
  507. for _, paths := range pages {
  508. var trees []*parse.Tree
  509. for _, localPath := range paths {
  510. tree, loadTreeErr := loadTree(localPath, luteEngine)
  511. if nil != loadTreeErr {
  512. continue
  513. }
  514. trees = append(trees, tree)
  515. }
  516. for _, tree := range trees {
  517. for _, d := range assetsLinkDestsInTree(tree) {
  518. dests[d] = true
  519. }
  520. if titleImgPath := treenode.GetDocTitleImgPath(tree.Root); "" != titleImgPath {
  521. // 题头图计入
  522. if !util.IsAssetLinkDest([]byte(titleImgPath)) {
  523. continue
  524. }
  525. dests[titleImgPath] = true
  526. }
  527. }
  528. }
  529. var linkDestFolderPaths, linkDestFilePaths []string
  530. for dest, _ := range dests {
  531. if !strings.HasPrefix(dest, "assets/") {
  532. continue
  533. }
  534. if idx := strings.Index(dest, "?"); 0 < idx {
  535. // `pdf?page` 资源文件链接会被判定为未引用资源 https://github.com/siyuan-note/siyuan/issues/5649
  536. dest = dest[:idx]
  537. }
  538. if "" == assetsPathMap[dest] {
  539. continue
  540. }
  541. if strings.HasSuffix(dest, "/") {
  542. linkDestFolderPaths = append(linkDestFolderPaths, dest)
  543. } else {
  544. linkDestFilePaths = append(linkDestFilePaths, dest)
  545. }
  546. }
  547. // 排除文件夹链接
  548. var toRemoves []string
  549. for asset, _ := range assetsPathMap {
  550. for _, linkDestFolder := range linkDestFolderPaths {
  551. if strings.HasPrefix(asset, linkDestFolder) {
  552. toRemoves = append(toRemoves, asset)
  553. }
  554. }
  555. for _, linkDestPath := range linkDestFilePaths {
  556. if strings.HasPrefix(linkDestPath, asset) {
  557. toRemoves = append(toRemoves, asset)
  558. }
  559. }
  560. }
  561. for _, toRemove := range toRemoves {
  562. delete(assetsPathMap, toRemove)
  563. }
  564. for _, dest := range linkDestFilePaths {
  565. linkDestMap[dest] = true
  566. if strings.HasSuffix(dest, ".pdf") {
  567. linkDestMap[dest+".sya"] = true
  568. }
  569. }
  570. }
  571. var toRemoves []string
  572. for asset, _ := range assetsPathMap {
  573. if strings.HasSuffix(asset, "ocr-texts.json") {
  574. // 排除 OCR 结果文本
  575. toRemoves = append(toRemoves, asset)
  576. continue
  577. }
  578. }
  579. for _, toRemove := range toRemoves {
  580. delete(assetsPathMap, toRemove)
  581. }
  582. dataAssetsAbsPath := util.GetDataAssetsAbsPath()
  583. for dest, assetAbsPath := range assetsPathMap {
  584. if _, ok := linkDestMap[dest]; ok {
  585. continue
  586. }
  587. var p string
  588. if strings.HasPrefix(dataAssetsAbsPath, assetAbsPath) {
  589. p = assetAbsPath[strings.Index(assetAbsPath, "assets"):]
  590. } else {
  591. p = strings.TrimPrefix(assetAbsPath, filepath.Dir(dataAssetsAbsPath))
  592. }
  593. p = filepath.ToSlash(p)
  594. if strings.HasPrefix(p, "/") {
  595. p = p[1:]
  596. }
  597. ret = append(ret, p)
  598. }
  599. sort.Strings(ret)
  600. return
  601. }
  602. func MissingAssets() (ret []string) {
  603. defer logging.Recover()
  604. ret = []string{}
  605. assetsPathMap, err := allAssetAbsPaths()
  606. if nil != err {
  607. return
  608. }
  609. notebooks, err := ListNotebooks()
  610. if nil != err {
  611. return
  612. }
  613. luteEngine := util.NewLute()
  614. for _, notebook := range notebooks {
  615. if notebook.Closed {
  616. continue
  617. }
  618. dests := map[string]bool{}
  619. pages := pagedPaths(filepath.Join(util.DataDir, notebook.ID), 32)
  620. for _, paths := range pages {
  621. var trees []*parse.Tree
  622. for _, localPath := range paths {
  623. tree, loadTreeErr := loadTree(localPath, luteEngine)
  624. if nil != loadTreeErr {
  625. continue
  626. }
  627. trees = append(trees, tree)
  628. }
  629. for _, tree := range trees {
  630. for _, d := range assetsLinkDestsInTree(tree) {
  631. dests[d] = true
  632. }
  633. if titleImgPath := treenode.GetDocTitleImgPath(tree.Root); "" != titleImgPath {
  634. // 题头图计入
  635. if !util.IsAssetLinkDest([]byte(titleImgPath)) {
  636. continue
  637. }
  638. dests[titleImgPath] = true
  639. }
  640. }
  641. }
  642. for dest, _ := range dests {
  643. if !strings.HasPrefix(dest, "assets/") {
  644. continue
  645. }
  646. if idx := strings.Index(dest, "?"); 0 < idx {
  647. dest = dest[:idx]
  648. }
  649. if strings.HasSuffix(dest, "/") {
  650. continue
  651. }
  652. if "" == assetsPathMap[dest] {
  653. if strings.HasPrefix(dest, "assets/.") {
  654. // Assets starting with `.` should not be considered missing assets https://github.com/siyuan-note/siyuan/issues/8821
  655. if !gulu.File.IsExist(filepath.Join(util.DataDir, dest)) {
  656. ret = append(ret, dest)
  657. }
  658. } else {
  659. ret = append(ret, dest)
  660. }
  661. continue
  662. }
  663. }
  664. }
  665. sort.Strings(ret)
  666. return
  667. }
  668. func emojisInTree(tree *parse.Tree) (ret []string) {
  669. ast.Walk(tree.Root, func(n *ast.Node, entering bool) ast.WalkStatus {
  670. if !entering {
  671. return ast.WalkContinue
  672. }
  673. if ast.NodeEmojiImg == n.Type {
  674. tokens := n.Tokens
  675. idx := bytes.Index(tokens, []byte("src=\""))
  676. if -1 == idx {
  677. return ast.WalkContinue
  678. }
  679. src := tokens[idx+len("src=\""):]
  680. src = src[:bytes.Index(src, []byte("\""))]
  681. ret = append(ret, string(src))
  682. }
  683. return ast.WalkContinue
  684. })
  685. ret = gulu.Str.RemoveDuplicatedElem(ret)
  686. return
  687. }
  688. func assetsLinkDestsInTree(tree *parse.Tree) (ret []string) {
  689. ret = []string{}
  690. ast.Walk(tree.Root, func(n *ast.Node, entering bool) ast.WalkStatus {
  691. // 修改以下代码时需要同时修改 database 构造行级元素实现,增加必要的类型
  692. if !entering || (ast.NodeLinkDest != n.Type && ast.NodeHTMLBlock != n.Type && ast.NodeInlineHTML != n.Type &&
  693. ast.NodeIFrame != n.Type && ast.NodeWidget != n.Type && ast.NodeAudio != n.Type && ast.NodeVideo != n.Type &&
  694. !n.IsTextMarkType("a") && !n.IsTextMarkType("file-annotation-ref")) {
  695. return ast.WalkContinue
  696. }
  697. if ast.NodeLinkDest == n.Type {
  698. if !isRelativePath(n.Tokens) {
  699. return ast.WalkContinue
  700. }
  701. dest := strings.TrimSpace(string(n.Tokens))
  702. ret = append(ret, dest)
  703. } else if n.IsTextMarkType("a") {
  704. if !isRelativePath(gulu.Str.ToBytes(n.TextMarkAHref)) {
  705. return ast.WalkContinue
  706. }
  707. dest := strings.TrimSpace(n.TextMarkAHref)
  708. ret = append(ret, dest)
  709. } else if n.IsTextMarkType("file-annotation-ref") {
  710. if !isRelativePath(gulu.Str.ToBytes(n.TextMarkFileAnnotationRefID)) {
  711. return ast.WalkContinue
  712. }
  713. if !strings.Contains(n.TextMarkFileAnnotationRefID, "/") {
  714. return ast.WalkContinue
  715. }
  716. dest := n.TextMarkFileAnnotationRefID[:strings.LastIndexByte(n.TextMarkFileAnnotationRefID, '/')]
  717. dest = strings.TrimSpace(dest)
  718. ret = append(ret, dest)
  719. } else {
  720. if ast.NodeWidget == n.Type {
  721. dataAssets := n.IALAttr("custom-data-assets")
  722. if "" == dataAssets {
  723. // 兼容两种属性名 custom-data-assets 和 data-assets https://github.com/siyuan-note/siyuan/issues/4122#issuecomment-1154796568
  724. dataAssets = n.IALAttr("data-assets")
  725. }
  726. if "" == dataAssets || !isRelativePath([]byte(dataAssets)) {
  727. return ast.WalkContinue
  728. }
  729. ret = append(ret, dataAssets)
  730. } else { // HTMLBlock/InlineHTML/IFrame/Audio/Video
  731. if index := bytes.Index(n.Tokens, []byte("src=\"")); 0 < index {
  732. src := n.Tokens[index+len("src=\""):]
  733. if index = bytes.Index(src, []byte("\"")); 0 < index {
  734. src = src[:bytes.Index(src, []byte("\""))]
  735. if !isRelativePath(src) {
  736. return ast.WalkContinue
  737. }
  738. dest := strings.TrimSpace(string(src))
  739. ret = append(ret, dest)
  740. } else {
  741. logging.LogWarnf("src is missing the closing double quote in tree [%s] ", tree.Box+tree.Path)
  742. }
  743. }
  744. }
  745. }
  746. return ast.WalkContinue
  747. })
  748. ret = gulu.Str.RemoveDuplicatedElem(ret)
  749. return
  750. }
  751. func isRelativePath(dest []byte) bool {
  752. if 1 > len(dest) {
  753. return false
  754. }
  755. if '/' == dest[0] {
  756. return false
  757. }
  758. return !bytes.Contains(dest, []byte(":"))
  759. }
  760. // allAssetAbsPaths 返回 asset 相对路径(assets/xxx)到绝对路径(F:\SiYuan\data\assets\xxx)的映射。
  761. func allAssetAbsPaths() (assetsAbsPathMap map[string]string, err error) {
  762. notebooks, err := ListNotebooks()
  763. if nil != err {
  764. return
  765. }
  766. assetsAbsPathMap = map[string]string{}
  767. // 笔记本 assets
  768. for _, notebook := range notebooks {
  769. notebookAbsPath := filepath.Join(util.DataDir, notebook.ID)
  770. filepath.Walk(notebookAbsPath, func(path string, info fs.FileInfo, err error) error {
  771. if notebookAbsPath == path {
  772. return nil
  773. }
  774. if isSkipFile(info.Name()) {
  775. if info.IsDir() {
  776. return filepath.SkipDir
  777. }
  778. return nil
  779. }
  780. if info.IsDir() && "assets" == info.Name() {
  781. filepath.Walk(path, func(assetPath string, info fs.FileInfo, err error) error {
  782. if path == assetPath {
  783. return nil
  784. }
  785. if isSkipFile(info.Name()) {
  786. if info.IsDir() {
  787. return filepath.SkipDir
  788. }
  789. return nil
  790. }
  791. relPath := filepath.ToSlash(assetPath)
  792. relPath = relPath[strings.Index(relPath, "assets/"):]
  793. if info.IsDir() {
  794. relPath += "/"
  795. }
  796. assetsAbsPathMap[relPath] = assetPath
  797. return nil
  798. })
  799. return filepath.SkipDir
  800. }
  801. return nil
  802. })
  803. }
  804. // 全局 assets
  805. dataAssetsAbsPath := util.GetDataAssetsAbsPath()
  806. filepath.Walk(dataAssetsAbsPath, func(assetPath string, info fs.FileInfo, err error) error {
  807. if dataAssetsAbsPath == assetPath {
  808. return nil
  809. }
  810. if isSkipFile(info.Name()) {
  811. if info.IsDir() {
  812. return filepath.SkipDir
  813. }
  814. return nil
  815. }
  816. relPath := filepath.ToSlash(assetPath)
  817. relPath = relPath[strings.Index(relPath, "assets/"):]
  818. if info.IsDir() {
  819. relPath += "/"
  820. }
  821. assetsAbsPathMap[relPath] = assetPath
  822. return nil
  823. })
  824. return
  825. }
  826. // copyBoxAssetsToDataAssets 将笔记本路径下所有(包括子文档)的 assets 复制一份到 data/assets 中。
  827. func copyBoxAssetsToDataAssets(boxID string) {
  828. boxLocalPath := filepath.Join(util.DataDir, boxID)
  829. copyAssetsToDataAssets(boxLocalPath)
  830. }
  831. // copyDocAssetsToDataAssets 将文档路径下所有(包括子文档)的 assets 复制一份到 data/assets 中。
  832. func copyDocAssetsToDataAssets(boxID, parentDocPath string) {
  833. boxLocalPath := filepath.Join(util.DataDir, boxID)
  834. parentDocDirAbsPath := filepath.Dir(filepath.Join(boxLocalPath, parentDocPath))
  835. copyAssetsToDataAssets(parentDocDirAbsPath)
  836. }
  837. func copyAssetsToDataAssets(rootPath string) {
  838. var assetsDirPaths []string
  839. filepath.Walk(rootPath, func(path string, info fs.FileInfo, err error) error {
  840. if rootPath == path || nil == info {
  841. return nil
  842. }
  843. isDir := info.IsDir()
  844. name := info.Name()
  845. if isSkipFile(name) {
  846. if isDir {
  847. return filepath.SkipDir
  848. }
  849. return nil
  850. }
  851. if "assets" == name && isDir {
  852. assetsDirPaths = append(assetsDirPaths, path)
  853. }
  854. return nil
  855. })
  856. dataAssetsPath := filepath.Join(util.DataDir, "assets")
  857. for _, assetsDirPath := range assetsDirPaths {
  858. if err := filelock.Copy(assetsDirPath, dataAssetsPath); nil != err {
  859. logging.LogErrorf("copy tree assets from [%s] to [%s] failed: %s", assetsDirPaths, dataAssetsPath, err)
  860. }
  861. }
  862. }