From cf7c9a1e58889bb1101cbf631c51e6ee4dce48c4 Mon Sep 17 00:00:00 2001 From: movsb Date: Wed, 27 Mar 2024 03:09:27 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=A7=A3=E6=9E=90=E5=B9=B6?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=96=87=E7=AB=A0=E9=99=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 1 - .gitignore | 2 +- README.md | 1 + cmd/client/backup.go | 3 +- cmd/client/main.go | 7 +- cmd/client/post.go | 156 +++++++++++++++++- cmd/client/post_test.go | 39 +++++ ...07\347\253\240\347\274\226\350\276\221.md" | 14 ++ theme/blog/styles/comment.scss | 2 +- 9 files changed, 210 insertions(+), 15 deletions(-) delete mode 100644 .dockerignore create mode 100644 cmd/client/post_test.go rename "docs/usage/\345\233\276\347\211\207.md" => "docs/usage/\346\226\207\347\253\240\347\274\226\350\276\221.md" (54%) diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index a5fc3962..00000000 --- a/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -/.tmp/ diff --git a/.gitignore b/.gitignore index a9e04278..77032c5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ *~ *.swp .DS_Store -__debug_bin +__debug_bin* /tmp/ /.tmp/ diff --git a/README.md b/README.md index 03a6595c..e6df797e 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ main.go | 入口程序 - [ ] 移除 jQuery(早期的代码和文章有依赖) - [ ] 把评论通知改成后台任务异步通知(因为可能失败,失败后应该重试) +- [ ] 显示未渲染的评论时,使用 html.innerText(实现方式:渲染成 html 后提取 标签内容并合并) ## 如果你想试一下 diff --git a/cmd/client/backup.go b/cmd/client/backup.go index d35d7f15..791cb0b9 100644 --- a/cmd/client/backup.go +++ b/cmd/client/backup.go @@ -4,7 +4,6 @@ import ( "compress/zlib" "fmt" "io" - "io/ioutil" "log" "os" "path/filepath" @@ -37,7 +36,7 @@ func (c *Client) BackupPosts(cmd *cobra.Command) { } r = zr } else { - r = ioutil.NopCloser(bpr) + r = io.NopCloser(bpr) } defer r.Close() diff --git a/cmd/client/main.go b/cmd/client/main.go index 961c99dd..a0220cc6 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -96,9 +96,10 @@ func AddCommands(rootCmd *cobra.Command) { } postsCmd.AddCommand(postsCreateCmd) postsUploadCmd := &cobra.Command{ - Use: `upload `, - Short: `Upload post assets, like images`, - Args: cobra.MinimumNArgs(1), + Use: `upload `, + Short: `Upload post assets, like images`, + Args: cobra.MinimumNArgs(1), + Deprecated: `将会自动上传文章附件,此命令不再需要手动执行。`, Run: func(cmd *cobra.Command, args []string) { client.UploadPostFiles(args) }, diff --git a/cmd/client/post.go b/cmd/client/post.go index b1c83014..df008d3e 100644 --- a/cmd/client/post.go +++ b/cmd/client/post.go @@ -3,11 +3,16 @@ package client import ( "errors" "fmt" + "log" "os" "path/filepath" "strings" "github.com/movsb/taoblog/protocols" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" + html5 "golang.org/x/net/html" field_mask "google.golang.org/protobuf/types/known/fieldmaskpb" yaml "gopkg.in/yaml.v2" ) @@ -38,6 +43,11 @@ type PostConfig struct { // InitPost ... func (c *Client) InitPost() error { + // 禁止意外在项目下创建。 + if _, err := os.Stat(`go.mod`); err == nil { + log.Fatalln(`不允许在项目根目录下创建文章。`) + } + fp, err := os.Open("config.yml") if err == nil { fp.Close() @@ -68,7 +78,9 @@ func (c *Client) CreatePost() error { p.Type = cfg.Type p.Metas = cfg.Metas - p.SourceType, p.Source = readSource(".") + var assets []string + + p.SourceType, p.Source, assets = readSource(".") rp, err := c.blog.CreatePost(c.token(), &p) if err != nil { @@ -79,6 +91,8 @@ func (c *Client) CreatePost() error { cfg.Modified = rp.Modified c.savePostConfig(&cfg) + c.UploadPostFiles(assets) + return nil } @@ -165,7 +179,9 @@ func (c *Client) UpdatePost() error { p.Type = cfg.Type p.Metas = cfg.Metas - p.SourceType, p.Source = readSource(".") + var assets []string + + p.SourceType, p.Source, assets = readSource(".") rp, err := c.blog.UpdatePost(c.token(), &protocols.UpdatePostRequest{ Post: &p, @@ -191,6 +207,8 @@ func (c *Client) UpdatePost() error { cfg.Metas = rp.Metas c.savePostConfig(&cfg) + c.UploadPostFiles(assets) + return nil } @@ -202,21 +220,23 @@ func (c *Client) DeletePost(id int64) error { return err } -// UploadPostFiles ... +// UploadPostFiles 上传文章附件。 +// TODO 目前为了简单起见,使用的是 HTTP POST 方式上传; +// TODO 应该像 Backup 那样改成带进度的 protocol buffer 方式上传。 func (c *Client) UploadPostFiles(files []string) { config := c.readPostConfig() if config.ID <= 0 { panic("post not posted, post it first.") } if len(files) <= 0 { - panic("Specify files.") + return } for _, file := range files { fmt.Println(" +", file) var err error fp, err := os.Open(file) if err != nil { - panic(err) + log.Fatalln(err) } defer fp.Close() path := fmt.Sprintf("/posts/%d/files/%s", config.ID, file) @@ -251,7 +271,7 @@ func (c *Client) savePostConfig(config *PostConfig) { } } -func readSource(dir string) (string, string) { +func readSource(dir string) (string, string, []string) { var source string var theName string @@ -277,14 +297,136 @@ func readSource(dir string) (string, string) { } typ := "" + var assets []string + var err error switch filepath.Ext(theName) { case ".md": typ = "markdown" + assets, err = parsePostAssets(source) + if err != nil { + log.Println(err) + } case ".html": typ = "html" } - return typ, source + return typ, source, assets +} + +// 从文章的源代码里面提取出附件列表。 +// 参考:docs/usage/文章编辑::自动附件上传 +// TODO 暂时放在 client 中,其实 server 中也可能用到,到时候再独立成公共模块 +// TODO 目前此函数只针对 Markdown 类型的文章,HTML 类型的文章不支持。 +func parsePostAssets(source string) ([]string, error) { + sourceBytes := []byte(source) + reader := text.NewReader(sourceBytes) + doc := goldmark.DefaultParser().Parse(reader) + + // 用来保存所有的相对路径列表 + var assets []string + + tryAdd := func(asset string) { + if strings.Contains(asset, `://`) || !filepath.IsLocal(asset) { + if asset != "" && (!strings.Contains(asset, `://`) && !filepath.IsAbs(asset)) { + log.Println(`maybe an invalid asset presents in the post:`, asset) + } + return + } + assets = append(assets, asset) + } + + fromHTML := func(html string) { + assets, err := parseHtmlAssets(html) + if err != nil { + log.Println(err) + } + for _, asset := range assets { + tryAdd(asset) + } + } + + ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + // 如果修改了这个列表,注意同时更新到文档。 + switch tag := n.(type) { + case *ast.Link: + tryAdd(string(tag.Destination)) + case *ast.Image: + tryAdd(string(tag.Destination)) + case *ast.HTMLBlock, *ast.RawHTML: + var lines *text.Segments + switch tag := n.(type) { + default: + panic(`unknown tag type`) + case *ast.HTMLBlock: + lines = tag.Lines() + case *ast.RawHTML: + lines = tag.Segments + } + + var rawLines []string + for i := 0; i < lines.Len(); i++ { + seg := lines.At(i) + value := seg.Value(sourceBytes) + rawLines = append(rawLines, string(value)) + } + fromHTML(strings.Join(rawLines, "\n")) + } + return ast.WalkContinue, nil + }) + + return assets, nil +} + +func parseHtmlAssets(html string) ([]string, error) { + node, err := html5.Parse(strings.NewReader(html)) + if err != nil { + return nil, err + } + + var assets []string + + var recurse func(node *html5.Node) + + // 先访问节点自身,再访问各子节点 + recurse = func(node *html5.Node) { + if !(node.Type == html5.DocumentNode || node.Type == html5.ElementNode) { + return + } + + // log.Println("Data:", node.Data) + var path string + var wantedAttr string + switch strings.ToLower(node.Data) { + case `a`: + wantedAttr = `href` + case `img`, `source`, `iframe`: + wantedAttr = `src` + case `object`: + wantedAttr = `data` + } + if wantedAttr != `` { + for _, attr := range node.Attr { + if strings.EqualFold(attr.Key, wantedAttr) { + path = attr.Val + } + } + } + if path != `` { + assets = append(assets, path) + } + + for child := node.FirstChild; child != nil; child = child.NextSibling { + recurse(child) + } + } + + recurse(node) + + return assets, nil } func (c *Client) SetRedirect(sourcePath, targetPath string) { diff --git a/cmd/client/post_test.go b/cmd/client/post_test.go new file mode 100644 index 00000000..e214b8d2 --- /dev/null +++ b/cmd/client/post_test.go @@ -0,0 +1,39 @@ +package client + +import ( + "strings" + "testing" +) + +func TestParsePostAssets(t *testing.T) { + tests := []struct { + Source string + Assets []string + }{ + { + Source: `a adf`, + Assets: []string{`a.jpg`}, + }, + { + Source: `a adf`, + Assets: []string{`a.jpg`}, + }, + } + for _, t1 := range tests { + assets, err := parsePostAssets(t1.Source) + if err != nil { + t.Error(err) + continue + } + if len(t1.Assets) != len(assets) { + t.Errorf(`assets not equal: %s`, t1.Source) + continue + } + for i := 0; i < len(t1.Assets); i++ { + if !strings.EqualFold(t1.Assets[i], assets[i]) { + t.Errorf(`assets not equal: %s`, t1.Source) + continue + } + } + } +} diff --git "a/docs/usage/\345\233\276\347\211\207.md" "b/docs/usage/\346\226\207\347\253\240\347\274\226\350\276\221.md" similarity index 54% rename from "docs/usage/\345\233\276\347\211\207.md" rename to "docs/usage/\346\226\207\347\253\240\347\274\226\350\276\221.md" index d69cb244..5bec5c09 100644 --- "a/docs/usage/\345\233\276\347\211\207.md" +++ "b/docs/usage/\346\226\207\347\253\240\347\274\226\350\276\221.md" @@ -1,3 +1,7 @@ +# 文章编辑 + +## 图片插入 + 在 Markdown 中可以直接使用 Markdown 语法插入图片: ```markdown @@ -9,3 +13,13 @@ ```html 图片加载失败时的替代文本 ``` + +## 附件自动上传 + +文章中的以下附件会自动识别并上传: + +* `` 标签中 `href` 为相对地址的 +* `` 标签中 `src` 为相对地址的 +* `` 标签中 `src` 为相对地址的 +* `