diff --git a/app/src/menus/commonMenuItem.ts b/app/src/menus/commonMenuItem.ts index b0beaa4bd..a659d0b15 100644 --- a/app/src/menus/commonMenuItem.ts +++ b/app/src/menus/commonMenuItem.ts @@ -635,12 +635,29 @@ export const exportMd = (id: string) => { icon: "iconMore", type: "submenu", submenu: [{ - label: "Word .docx", - icon: "iconExact", + label: "reStructuredText", click: () => { - saveExport({type: "word", id}); + const msgId = showMessage(window.siyuan.languages.exporting, -1); + fetchPost("/api/export/exportReStructuredText", { + id, + }, response => { + hideMessage(msgId); + openByMobile(response.data.zip); + }); } - }] + }, { + label: "AsciiDoc", + click: () => { + const msgId = showMessage(window.siyuan.languages.exporting, -1); + fetchPost("/api/export/exportAsciiDoc", { + id, + }, response => { + hideMessage(msgId); + openByMobile(response.data.zip); + }); + } + }, + ] } /// #endif ] diff --git a/kernel/api/export.go b/kernel/api/export.go index 602fa033f..02190ef2e 100644 --- a/kernel/api/export.go +++ b/kernel/api/export.go @@ -31,6 +31,40 @@ import ( "github.com/siyuan-note/siyuan/kernel/util" ) +func exportAsciiDoc(c *gin.Context) { + ret := gulu.Ret.NewResult() + defer c.JSON(http.StatusOK, ret) + + arg, ok := util.JsonArg(c, ret) + if !ok { + return + } + + id := arg["id"].(string) + name, zipPath := model.ExportPandocConvertZip(id, "asciidoc", ".adoc") + ret.Data = map[string]interface{}{ + "name": name, + "zip": zipPath, + } +} + +func exportReStructuredText(c *gin.Context) { + ret := gulu.Ret.NewResult() + defer c.JSON(http.StatusOK, ret) + + arg, ok := util.JsonArg(c, ret) + if !ok { + return + } + + id := arg["id"].(string) + name, zipPath := model.ExportPandocConvertZip(id, "rst", ".rst") + ret.Data = map[string]interface{}{ + "name": name, + "zip": zipPath, + } +} + func export2Liandi(c *gin.Context) { ret := gulu.Ret.NewResult() defer c.JSON(http.StatusOK, ret) @@ -115,7 +149,7 @@ func exportMd(c *gin.Context) { } id := arg["id"].(string) - name, zipPath := model.ExportMarkdown(id) + name, zipPath := model.ExportPandocConvertZip(id, "", ".md") ret.Data = map[string]interface{}{ "name": name, "zip": zipPath, diff --git a/kernel/api/router.go b/kernel/api/router.go index 8fff99568..183c6496a 100644 --- a/kernel/api/router.go +++ b/kernel/api/router.go @@ -245,6 +245,8 @@ func ServeAPI(ginServer *gin.Engine) { ginServer.Handle("POST", "/api/export/exportDataInFolder", model.CheckAuth, exportDataInFolder) ginServer.Handle("POST", "/api/export/exportTempContent", model.CheckAuth, exportTempContent) ginServer.Handle("POST", "/api/export/export2Liandi", model.CheckAuth, export2Liandi) + ginServer.Handle("POST", "/api/export/exportReStructuredText", model.CheckAuth, exportReStructuredText) + ginServer.Handle("POST", "/api/export/exportAsciiDoc", model.CheckAuth, exportAsciiDoc) ginServer.Handle("POST", "/api/import/importStdMd", model.CheckAuth, model.CheckReadonly, importStdMd) ginServer.Handle("POST", "/api/import/importData", model.CheckAuth, model.CheckReadonly, importData) diff --git a/kernel/model/export.go b/kernel/model/export.go index b2f2ac21f..c61ba3d25 100644 --- a/kernel/model/export.go +++ b/kernel/model/export.go @@ -984,7 +984,7 @@ func ExportStdMarkdown(id string) string { Conf.Export.AddTitle) } -func ExportMarkdown(id string) (name, zipPath string) { +func ExportPandocConvertZip(id, pandocTo, ext string) (name, zipPath string) { block := treenode.GetBlockTree(id) if nil == block { logging.LogErrorf("not found block [%s]", id) @@ -1002,7 +1002,8 @@ func ExportMarkdown(id string) (name, zipPath string) { for _, docFile := range docFiles { docPaths = append(docPaths, docFile.path) } - zipPath = exportMarkdownZip(boxID, baseFolderName, docPaths) + + zipPath = exportPandocConvertZip(boxID, baseFolderName, docPaths, "gfm+footnotes", pandocTo, ext) name = strings.TrimSuffix(filepath.Base(block.Path), ".sy") return } @@ -1030,114 +1031,7 @@ func BatchExportMarkdown(boxID, folderPath string) (zipPath string) { for _, docFile := range docFiles { docPaths = append(docPaths, docFile.path) } - zipPath = exportMarkdownZip(boxID, baseFolderName, docPaths) - return -} - -func exportMarkdownZip(boxID, baseFolderName string, docPaths []string) (zipPath string) { - dir, name := path.Split(baseFolderName) - name = util.FilterFileName(name) - if strings.HasSuffix(name, "..") { - // 文档标题以 `..` 结尾时无法导出 Markdown https://github.com/siyuan-note/siyuan/issues/4698 - // 似乎是 os.MkdirAll 的 bug,以 .. 结尾的路径无法创建,所以这里加上 _ 结尾 - name += "_" - } - baseFolderName = path.Join(dir, name) - box := Conf.Box(boxID) - - exportFolder := filepath.Join(util.TempDir, "export", baseFolderName) - if err := os.MkdirAll(exportFolder, 0755); nil != err { - logging.LogErrorf("create export temp folder failed: %s", err) - return - } - - luteEngine := util.NewLute() - for _, p := range docPaths { - docIAL := box.docIAL(p) - if nil == docIAL { - continue - } - - id := docIAL["id"] - hPath, md := exportMarkdownContent(id) - dir, name = path.Split(hPath) - dir = util.FilterFilePath(dir) // 导出文档时未移除不支持的文件名符号 https://github.com/siyuan-note/siyuan/issues/4590 - name = util.FilterFileName(name) - hPath = path.Join(dir, name) - p = hPath + ".md" - writePath := filepath.Join(exportFolder, p) - if gulu.File.IsExist(writePath) { - // 重名文档加 ID - p = hPath + "-" + id + ".md" - writePath = filepath.Join(exportFolder, p) - } - writeFolder := filepath.Dir(writePath) - if err := os.MkdirAll(writeFolder, 0755); nil != err { - logging.LogErrorf("create export temp folder [%s] failed: %s", writeFolder, err) - continue - } - if err := gulu.File.WriteFileSafer(writePath, gulu.Str.ToBytes(md), 0644); nil != err { - logging.LogErrorf("write export markdown file [%s] failed: %s", writePath, err) - continue - } - - // 解析导出后的标准 Markdown,汇总 assets - tree := parse.Parse("", gulu.Str.ToBytes(md), luteEngine.ParseOptions) - var assets []string - assets = append(assets, assetsLinkDestsInTree(tree)...) - for _, asset := range assets { - asset = string(html.DecodeDestination([]byte(asset))) - if strings.Contains(asset, "?") { - asset = asset[:strings.LastIndex(asset, "?")] - } - - srcPath, err := GetAssetAbsPath(asset) - if nil != err { - logging.LogWarnf("get asset [%s] abs path failed: %s", asset, err) - continue - } - - destPath := filepath.Join(writeFolder, asset) - err = filelock.Copy(srcPath, destPath) - if nil != err { - logging.LogErrorf("copy asset from [%s] to [%s] failed: %s", srcPath, destPath, err) - continue - } - } - } - - zipPath = exportFolder + ".zip" - zip, err := gulu.Zip.Create(zipPath) - if nil != err { - logging.LogErrorf("create export markdown zip [%s] failed: %s", exportFolder, err) - return "" - } - - // 导出 Markdown zip 包内不带文件夹 https://github.com/siyuan-note/siyuan/issues/6869 - entries, err := os.ReadDir(exportFolder) - if nil != err { - logging.LogErrorf("read export markdown folder [%s] failed: %s", exportFolder, err) - return "" - } - for _, entry := range entries { - entryPath := filepath.Join(exportFolder, entry.Name()) - if gulu.File.IsDir(entryPath) { - err = zip.AddDirectory(entry.Name(), entryPath) - } else { - err = zip.AddEntry(entry.Name(), entryPath) - } - if nil != err { - logging.LogErrorf("add entry [%s] to zip failed: %s", entry.Name(), err) - return "" - } - } - - if err = zip.Close(); nil != err { - logging.LogErrorf("close export markdown zip failed: %s", err) - } - - os.RemoveAll(exportFolder) - zipPath = "/export/" + url.PathEscape(filepath.Base(zipPath)) + zipPath = exportPandocConvertZip(boxID, baseFolderName, docPaths, "", "md", ".md") return } @@ -1391,17 +1285,17 @@ func exportSYZip(boxID, rootDirPath, baseFolderName string, docPaths []string) ( zipPath = exportFolder + ".sy.zip" zip, err := gulu.Zip.Create(zipPath) if nil != err { - logging.LogErrorf("create export markdown zip [%s] failed: %s", exportFolder, err) + logging.LogErrorf("create export .sy.zip [%s] failed: %s", exportFolder, err) return "" } if err = zip.AddDirectory(baseFolderName, exportFolder); nil != err { - logging.LogErrorf("create export markdown zip [%s] failed: %s", exportFolder, err) + logging.LogErrorf("create export .sy.zip [%s] failed: %s", exportFolder, err) return "" } if err = zip.Close(); nil != err { - logging.LogErrorf("close export markdown zip failed: %s", err) + logging.LogErrorf("close export .sy.zip failed: %s", err) } os.RemoveAll(exportFolder) @@ -2089,3 +1983,119 @@ func processFileAnnotationRef(refID string, n *ast.Node, fileAnnotationRefMode i n.InsertBefore(fileAnnotationRefLink) return ast.WalkSkipChildren } + +func exportPandocConvertZip(boxID, baseFolderName string, docPaths []string, + pandocFrom, pandocTo, ext string) (zipPath string) { + dir, name := path.Split(baseFolderName) + name = util.FilterFileName(name) + if strings.HasSuffix(name, "..") { + // 文档标题以 `..` 结尾时无法导出 Markdown https://github.com/siyuan-note/siyuan/issues/4698 + // 似乎是 os.MkdirAll 的 bug,以 .. 结尾的路径无法创建,所以这里加上 _ 结尾 + name += "_" + } + baseFolderName = path.Join(dir, name) + box := Conf.Box(boxID) + + exportFolder := filepath.Join(util.TempDir, "export", baseFolderName+ext) + if err := os.MkdirAll(exportFolder, 0755); nil != err { + logging.LogErrorf("create export temp folder failed: %s", err) + return + } + + luteEngine := util.NewLute() + for _, p := range docPaths { + docIAL := box.docIAL(p) + if nil == docIAL { + continue + } + + id := docIAL["id"] + hPath, md := exportMarkdownContent(id) + dir, name = path.Split(hPath) + dir = util.FilterFilePath(dir) // 导出文档时未移除不支持的文件名符号 https://github.com/siyuan-note/siyuan/issues/4590 + name = util.FilterFileName(name) + hPath = path.Join(dir, name) + p = hPath + ext + writePath := filepath.Join(exportFolder, p) + if gulu.File.IsExist(writePath) { + // 重名文档加 ID + p = hPath + "-" + id + ext + writePath = filepath.Join(exportFolder, p) + } + writeFolder := filepath.Dir(writePath) + if err := os.MkdirAll(writeFolder, 0755); nil != err { + logging.LogErrorf("create export temp folder [%s] failed: %s", writeFolder, err) + continue + } + + // 调用 Pandoc 进行格式转换 + output, err := util.Pandoc(pandocFrom, pandocTo, md) + if nil != err { + logging.LogErrorf("pandoc failed: %s", err) + continue + } + + if err := gulu.File.WriteFileSafer(writePath, gulu.Str.ToBytes(output), 0644); nil != err { + logging.LogErrorf("write export markdown file [%s] failed: %s", writePath, err) + continue + } + + // 解析导出后的标准 Markdown,汇总 assets + tree := parse.Parse("", gulu.Str.ToBytes(md), luteEngine.ParseOptions) + var assets []string + assets = append(assets, assetsLinkDestsInTree(tree)...) + for _, asset := range assets { + asset = string(html.DecodeDestination([]byte(asset))) + if strings.Contains(asset, "?") { + asset = asset[:strings.LastIndex(asset, "?")] + } + + srcPath, err := GetAssetAbsPath(asset) + if nil != err { + logging.LogWarnf("get asset [%s] abs path failed: %s", asset, err) + continue + } + + destPath := filepath.Join(writeFolder, asset) + err = filelock.Copy(srcPath, destPath) + if nil != err { + logging.LogErrorf("copy asset from [%s] to [%s] failed: %s", srcPath, destPath, err) + continue + } + } + } + + zipPath = exportFolder + ".zip" + zip, err := gulu.Zip.Create(zipPath) + if nil != err { + logging.LogErrorf("create export markdown zip [%s] failed: %s", exportFolder, err) + return "" + } + + // 导出 Markdown zip 包内不带文件夹 https://github.com/siyuan-note/siyuan/issues/6869 + entries, err := os.ReadDir(exportFolder) + if nil != err { + logging.LogErrorf("read export markdown folder [%s] failed: %s", exportFolder, err) + return "" + } + for _, entry := range entries { + entryPath := filepath.Join(exportFolder, entry.Name()) + if gulu.File.IsDir(entryPath) { + err = zip.AddDirectory(entry.Name(), entryPath) + } else { + err = zip.AddEntry(entry.Name(), entryPath) + } + if nil != err { + logging.LogErrorf("add entry [%s] to zip failed: %s", entry.Name(), err) + return "" + } + } + + if err = zip.Close(); nil != err { + logging.LogErrorf("close export markdown zip failed: %s", err) + } + + os.RemoveAll(exportFolder) + zipPath = "/export/" + url.PathEscape(filepath.Base(zipPath)) + return +} diff --git a/kernel/util/pandoc.go b/kernel/util/pandoc.go new file mode 100644 index 000000000..c995c5c2a --- /dev/null +++ b/kernel/util/pandoc.go @@ -0,0 +1,125 @@ +// SiYuan - Build Your Eternal Digital Garden +// Copyright (c) 2020-present, b3log.org +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package util + +import ( + "bytes" + "github.com/88250/gulu" + "github.com/siyuan-note/logging" + "os/exec" + "path/filepath" + "strings" +) + +func Pandoc(from, to, content string) (ret string, err error) { + if "" == from || "" == to || "md" == to { + ret = content + return + } + + args := []string{ + "--from", from, + "--to", to, + } + + pandoc := exec.Command(PandocBinPath, args...) + gulu.CmdAttr(pandoc) + pandoc.Stdin = bytes.NewBufferString(content) + output, err := pandoc.CombinedOutput() + if nil != err { + return + } + ret = string(output) + return +} + +var ( + PandocBinPath string // Pandoc 可执行文件路径 +) + +func initPandoc() { + if ContainerStd != Container { + return + } + + pandocDir := filepath.Join(TempDir, "pandoc") + if gulu.OS.IsWindows() { + PandocBinPath = filepath.Join(pandocDir, "bin", "pandoc.exe") + } else if gulu.OS.IsDarwin() || gulu.OS.IsLinux() { + PandocBinPath = filepath.Join(pandocDir, "bin", "pandoc") + } + pandocVer := getPandocVer(PandocBinPath) + if "" != pandocVer { + logging.LogInfof("built-in pandoc [ver=%s, bin=%s]", pandocVer, PandocBinPath) + return + } + + pandocZip := filepath.Join(WorkingDir, "pandoc.zip") + if "dev" == Mode || !gulu.File.IsExist(pandocZip) { + if gulu.OS.IsWindows() { + pandocZip = filepath.Join(WorkingDir, "pandoc/pandoc-windows-amd64.zip") + } else if gulu.OS.IsDarwin() { + pandocZip = filepath.Join(WorkingDir, "pandoc/pandoc-darwin-amd64.zip") + } else if gulu.OS.IsLinux() { + pandocZip = filepath.Join(WorkingDir, "pandoc/pandoc-linux-amd64.zip") + } + } + if err := gulu.Zip.Unzip(pandocZip, pandocDir); nil != err { + logging.LogErrorf("unzip pandoc failed: %s", err) + return + } + + if gulu.OS.IsDarwin() || gulu.OS.IsLinux() { + exec.Command("chmod", "+x", PandocBinPath).CombinedOutput() + } + pandocVer = getPandocVer(PandocBinPath) + logging.LogInfof("initialized built-in pandoc [ver=%s, bin=%s]", pandocVer, PandocBinPath) +} + +func getPandocVer(binPath string) (ret string) { + if "" == binPath { + return + } + + cmd := exec.Command(binPath, "--version") + gulu.CmdAttr(cmd) + data, err := cmd.CombinedOutput() + if nil == err && strings.HasPrefix(string(data), "pandoc") { + parts := bytes.Split(data, []byte("\n")) + if 0 < len(parts) { + ret = strings.TrimPrefix(string(parts[0]), "pandoc") + ret = strings.ReplaceAll(ret, ".exe", "") + ret = strings.TrimSpace(ret) + } + return + } + return +} + +func IsValidPandocBin(binPath string) bool { + if "" == binPath { + return false + } + + cmd := exec.Command(binPath, "--version") + gulu.CmdAttr(cmd) + data, err := cmd.CombinedOutput() + if nil == err && strings.HasPrefix(string(data), "pandoc") { + return true + } + return false +} diff --git a/kernel/util/working.go b/kernel/util/working.go index f959b1054..ea6a42faa 100644 --- a/kernel/util/working.go +++ b/kernel/util/working.go @@ -17,14 +17,12 @@ package util import ( - "bytes" "errors" "flag" "fmt" "math/rand" "mime" "os" - "os/exec" "path/filepath" "strconv" "strings" @@ -173,7 +171,6 @@ var ( DBPath string // SQLite 数据库文件路径 HistoryDBPath string // SQLite 历史数据库文件路径 BlockTreePath string // 区块树文件路径 - PandocBinPath string // Pandoc 可执行文件路径 AppearancePath string // 配置目录下的外观目录 appearance/ 路径 ThemesPath string // 配置目录下的外观目录下的 themes/ 路径 IconsPath string // 配置目录下的外观目录下的 icons/ 路径 @@ -390,79 +387,6 @@ func initMime() { mime.AddExtensionType(".sy", "application/json") } -func initPandoc() { - if ContainerStd != Container { - return - } - - pandocDir := filepath.Join(TempDir, "pandoc") - if gulu.OS.IsWindows() { - PandocBinPath = filepath.Join(pandocDir, "bin", "pandoc.exe") - } else if gulu.OS.IsDarwin() || gulu.OS.IsLinux() { - PandocBinPath = filepath.Join(pandocDir, "bin", "pandoc") - } - pandocVer := getPandocVer(PandocBinPath) - if "" != pandocVer { - logging.LogInfof("built-in pandoc [ver=%s, bin=%s]", pandocVer, PandocBinPath) - return - } - - pandocZip := filepath.Join(WorkingDir, "pandoc.zip") - if "dev" == Mode || !gulu.File.IsExist(pandocZip) { - if gulu.OS.IsWindows() { - pandocZip = filepath.Join(WorkingDir, "pandoc/pandoc-windows-amd64.zip") - } else if gulu.OS.IsDarwin() { - pandocZip = filepath.Join(WorkingDir, "pandoc/pandoc-darwin-amd64.zip") - } else if gulu.OS.IsLinux() { - pandocZip = filepath.Join(WorkingDir, "pandoc/pandoc-linux-amd64.zip") - } - } - if err := gulu.Zip.Unzip(pandocZip, pandocDir); nil != err { - logging.LogErrorf("unzip pandoc failed: %s", err) - return - } - - if gulu.OS.IsDarwin() || gulu.OS.IsLinux() { - exec.Command("chmod", "+x", PandocBinPath).CombinedOutput() - } - pandocVer = getPandocVer(PandocBinPath) - logging.LogInfof("initialized built-in pandoc [ver=%s, bin=%s]", pandocVer, PandocBinPath) -} - -func getPandocVer(binPath string) (ret string) { - if "" == binPath { - return - } - - cmd := exec.Command(binPath, "--version") - gulu.CmdAttr(cmd) - data, err := cmd.CombinedOutput() - if nil == err && strings.HasPrefix(string(data), "pandoc") { - parts := bytes.Split(data, []byte("\n")) - if 0 < len(parts) { - ret = strings.TrimPrefix(string(parts[0]), "pandoc") - ret = strings.ReplaceAll(ret, ".exe", "") - ret = strings.TrimSpace(ret) - } - return - } - return -} - -func IsValidPandocBin(binPath string) bool { - if "" == binPath { - return false - } - - cmd := exec.Command(binPath, "--version") - gulu.CmdAttr(cmd) - data, err := cmd.CombinedOutput() - if nil == err && strings.HasPrefix(string(data), "pandoc") { - return true - } - return false -} - func GetDataAssetsAbsPath() (ret string) { ret = filepath.Join(DataDir, "assets") var err error