From 85e2da054bbffa95c808410006b52218c924d97e Mon Sep 17 00:00:00 2001 From: Thomas Miceli <27960254+thomiceli@users.noreply.github.com> Date: Tue, 26 Dec 2023 03:23:47 +0100 Subject: [PATCH] Add clickable Markdown checkboxes (#182) --- Makefile | 2 +- go.mod | 2 ++ go.sum | 4 +++ internal/db/gist.go | 22 +++++++++++- internal/git/commands.go | 12 ++++--- internal/git/commands_test.go | 2 +- internal/render/markdown.go | 66 +++++++++++++++++++++++++++++++++++ internal/web/gist.go | 36 +++++++++++++++++++ internal/web/server.go | 1 + public/gist.ts | 36 +++++++++++++++++++ templates/pages/gist.html | 3 +- 11 files changed, 177 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 6e46424..b6885a4 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ install: build_frontend: @echo "Building frontend assets..." npx vite build - EMBED=1 npx postcss 'public/assets/embed-*.css' -c postcss.config.js --replace # until we can .nest { @tailwind } in Sass + @EMBED=1 npx postcss 'public/assets/embed-*.css' -c postcss.config.js --replace # until we can .nest { @tailwind } in Sass build_backend: @echo "Building Opengist binary..." diff --git a/go.mod b/go.mod index eb4c287..c4f2b3a 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/thomiceli/opengist go 1.20 require ( + github.com/Kunde21/markdownfmt/v3 v3.1.0 github.com/alecthomas/chroma/v2 v2.12.0 github.com/dustin/go-humanize v1.0.1 github.com/glebarez/go-sqlite v1.21.2 @@ -43,6 +44,7 @@ require ( github.com/leodido/go-urn v1.2.4 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect diff --git a/go.sum b/go.sum index 0fe936e..f3b20ca 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Kunde21/markdownfmt/v3 v3.1.0 h1:KiZu9LKs+wFFBQKhrZJrFZwtLnCCWJahL+S+E/3VnM0= +github.com/Kunde21/markdownfmt/v3 v3.1.0/go.mod h1:tPXN1RTyOzJwhfHoon9wUr4HGYmWgVxSQN6VBJDkrVc= github.com/alecthomas/assert/v2 v2.2.1 h1:XivOgYcduV98QCahG8T5XTezV5bylXe+lBxLG2K2ink= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= github.com/alecthomas/chroma/v2 v2.12.0 h1:Wh8qLEgMMsN7mgyG8/qIpegky2Hvzr4By6gEF7cmWgw= @@ -207,6 +209,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mrjones/oauth v0.0.0-20180629183705-f4e24b6d100c/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/db/gist.go b/internal/db/gist.go index 120ce51..cc5d7a1 100644 --- a/internal/db/gist.go +++ b/internal/db/gist.go @@ -365,7 +365,7 @@ func (gist *Gist) NbCommits() (string, error) { } func (gist *Gist) AddAndCommitFiles(files *[]FileDTO) error { - if err := git.CloneTmp(gist.User.Username, gist.Uuid, gist.Uuid, gist.User.Email); err != nil { + if err := git.CloneTmp(gist.User.Username, gist.Uuid, gist.Uuid, gist.User.Email, true); err != nil { return err } @@ -386,6 +386,26 @@ func (gist *Gist) AddAndCommitFiles(files *[]FileDTO) error { return git.Push(gist.Uuid) } +func (gist *Gist) AddAndCommitFile(file *FileDTO) error { + if err := git.CloneTmp(gist.User.Username, gist.Uuid, gist.Uuid, gist.User.Email, false); err != nil { + return err + } + + if err := git.SetFileContent(gist.Uuid, file.Filename, file.Content); err != nil { + return err + } + + if err := git.AddAll(gist.Uuid); err != nil { + return err + } + + if err := git.CommitRepository(gist.Uuid, gist.User.Username, gist.User.Email); err != nil { + return err + } + + return git.Push(gist.Uuid) +} + func (gist *Gist) ForkClone(username string, uuid string) error { return git.ForkClone(gist.User.Username, gist.Uuid, username, uuid) } diff --git a/internal/git/commands.go b/internal/git/commands.go index 952370e..058dfb1 100644 --- a/internal/git/commands.go +++ b/internal/git/commands.go @@ -201,7 +201,7 @@ func GetLog(user string, gist string, skip int) ([]*Commit, error) { return parseLog(stdout, truncateLimit), err } -func CloneTmp(user string, gist string, gistTmpId string, email string) error { +func CloneTmp(user string, gist string, gistTmpId string, email string, remove bool) error { repositoryPath := RepositoryPath(user, gist) tmpPath := TmpRepositoriesPath() @@ -219,11 +219,13 @@ func CloneTmp(user string, gist string, gistTmpId string, email string) error { return err } - // remove every file (and not the .git directory!) - if err = removeFilesExceptGit(tmpRepositoryPath); err != nil { - return err + // remove every file (keep the .git directory) + // useful when user wants to edit multiple files from an existing gist + if remove { + if err = removeFilesExceptGit(tmpRepositoryPath); err != nil { + return err + } } - cmd = exec.Command("git", "config", "--local", "user.name", user) cmd.Dir = tmpRepositoryPath if err = cmd.Run(); err != nil { diff --git a/internal/git/commands_test.go b/internal/git/commands_test.go index c879430..e55f8c3 100644 --- a/internal/git/commands_test.go +++ b/internal/git/commands_test.go @@ -272,7 +272,7 @@ func TestInitViaGitInit(t *testing.T) { } func commitToBare(t *testing.T, user string, gist string, files map[string]string) { - err := CloneTmp(user, gist, gist, "thomas@mail.com") + err := CloneTmp(user, gist, gist, "thomas@mail.com", true) require.NoError(t, err, "Could not commit to repository") if len(files) > 0 { diff --git a/internal/render/markdown.go b/internal/render/markdown.go index c3c1d16..289cd17 100644 --- a/internal/render/markdown.go +++ b/internal/render/markdown.go @@ -1,15 +1,24 @@ package render import ( + "bufio" "bytes" + "github.com/Kunde21/markdownfmt/v3" "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/rs/zerolog/log" "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/git" "github.com/yuin/goldmark" emoji "github.com/yuin/goldmark-emoji" highlighting "github.com/yuin/goldmark-highlighting/v2" + "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" + astex "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" "go.abhg.dev/goldmark/mermaid" + "strconv" ) func MarkdownGistPreview(gist *db.Gist) (RenderedGist, error) { @@ -43,5 +52,62 @@ func newMarkdown() goldmark.Markdown { emoji.Emoji, &mermaid.Extender{}, ), + goldmark.WithParserOptions( + parser.WithASTTransformers( + util.Prioritized(&CheckboxTransformer{}, 10000), + ), + ), ) } + +type CheckboxTransformer struct{} + +func (t *CheckboxTransformer) Transform(node *ast.Document, _ text.Reader, _ parser.Context) { + i := 1 + err := ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + if _, ok := n.(*astex.TaskCheckBox); ok { + listitem := n.Parent().Parent() + listitem.SetAttribute([]byte("data-checkbox-nb"), []byte(strconv.Itoa(i))) + i += 1 + } + } + return ast.WalkContinue, nil + }) + if err != nil { + log.Err(err) + } +} + +func Checkbox(content string, checkboxNb int) (string, error) { + buf := bytes.Buffer{} + w := bufio.NewWriter(&buf) + + source := []byte(content) + markdown := markdownfmt.NewGoldmark() + reader := text.NewReader(source) + document := markdown.Parser().Parse(reader) + + i := 1 + err := ast.Walk(document, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + if listItem, ok := n.(*astex.TaskCheckBox); ok { + if i == checkboxNb { + listItem.IsChecked = !listItem.IsChecked + } + i += 1 + } + } + return ast.WalkContinue, nil + }) + if err != nil { + return "", err + } + + if err = markdown.Renderer().Render(w, source, document); err != nil { + return "", err + } + _ = w.Flush() + + return buf.String(), nil +} diff --git a/internal/web/gist.go b/internal/web/gist.go index e633f5f..b4585b5 100644 --- a/internal/web/gist.go +++ b/internal/web/gist.go @@ -787,3 +787,39 @@ func forks(ctx echo.Context) error { setData(ctx, "revision", "HEAD") return html(ctx, "forks.html") } + +func checkbox(ctx echo.Context) error { + filename := ctx.FormValue("file") + checkboxNb := ctx.FormValue("checkbox") + + i, err := strconv.Atoi(checkboxNb) + if err != nil { + return errorRes(400, "Invalid number", nil) + } + + gist := getData(ctx, "gist").(*db.Gist) + file, err := gist.File("HEAD", filename, false) + if err != nil { + return errorRes(500, "Error getting file content", err) + } else if file == nil { + return notFound("File not found") + } + + markdown, err := render.Checkbox(file.Content, i) + if err != nil { + return errorRes(500, "Error checking checkbox", err) + } + + if err = gist.AddAndCommitFile(&db.FileDTO{ + Filename: filename, + Content: markdown, + }); err != nil { + return errorRes(500, "Error adding and committing files", err) + } + + if err = gist.UpdatePreviewAndCount(); err != nil { + return errorRes(500, "Error updating the gist", err) + } + + return plainText(ctx, 200, "ok") +} diff --git a/internal/web/server.go b/internal/web/server.go index e9a3407..041adcf 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -265,6 +265,7 @@ func NewServer(isDev bool) *Server { g3.GET("/likes", likes) g3.POST("/fork", fork, logged) g3.GET("/forks", forks) + g3.PUT("/checkbox", checkbox, logged, writePermission) } } diff --git a/public/gist.ts b/public/gist.ts index e87ff65..db2a249 100644 --- a/public/gist.ts +++ b/public/gist.ts @@ -33,6 +33,42 @@ document.querySelectorAll('.md-code-copy-btn').forEach(button => { }); }); +let checkboxes = document.querySelectorAll('li[data-checkbox-nb] input[type=checkbox]'); +document.querySelectorAll('li[data-checkbox-nb]').forEach((el) => { + let input = el.firstElementChild; + (input as HTMLButtonElement).disabled = false; + let checkboxNb = (el as HTMLElement).dataset.checkboxNb; + let filename = input.parentElement.parentElement.parentElement.parentElement.parentElement.dataset.file; + + input.addEventListener('change', function () { + const data = new URLSearchParams(); + data.append('checkbox', checkboxNb); + data.append('file', filename); + if (document.getElementsByName('_csrf').length !== 0) { + data.append('_csrf', ((document.getElementsByName('_csrf')[0] as HTMLInputElement).value)); + } + checkboxes.forEach((el: HTMLButtonElement) => { + el.disabled = true; + el.classList.add('text-gray-400') + }); + fetch(window.location.href.split('#')[0] + '/checkbox', { + method: 'PUT', + credentials: 'same-origin', + body: data, + }).then((response) => { + if (response.status === 200) { + checkboxes.forEach((el: HTMLButtonElement) => { + el.disabled = false; + el.classList.remove('text-gray-400') + }); + } + }); + }) + + + +}) + diff --git a/templates/pages/gist.html b/templates/pages/gist.html index 8738a6d..b14e442 100644 --- a/templates/pages/gist.html +++ b/templates/pages/gist.html @@ -4,7 +4,7 @@
{{ range $file := .files }} {{ $csv := csvFile $file.File }} -
+
@@ -97,6 +97,7 @@ +