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<HTMLElement>('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 @@
         <div class="grid gap-y-4">
         {{ range $file := .files }}
         {{ $csv := csvFile $file.File }}
-        <div class="rounded-md border border-1 border-gray-200 dark:border-gray-700 overflow-auto">
+        <div class="rounded-md border border-1 border-gray-200 dark:border-gray-700 overflow-auto" data-file="{{ $file.Filename }}">
             <div class="border-b-1 border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 my-auto block">
                 <div class="ml-4 py-1.5 flex">
 
@@ -97,6 +97,7 @@
 
 <!-- make sure tailwind knows those classes -->
 <button type="button" style="top: 1em !important; right: 1em !important;" class="hidden md-code-copy-btn absolute right-0 top-0 focus-within:z-auto rounded-md dark:border-gray-600 px-2 py-2 opacity-80 font-medium text-slate-700 bg-gray-100 dark:bg-gray-700 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-gray-600 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 7.5V6.108c0-1.135.845-2.098 1.976-2.192.373-.03.748-.057 1.123-.08M15.75 18H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08M15.75 18.75v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5A3.375 3.375 0 006.375 7.5H5.25m11.9-3.664A2.251 2.251 0 0015 2.25h-1.5a2.251 2.251 0 00-2.15 1.586m5.8 0c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75 7.5H4.875c-.621 0-1.125.504-1.125 1.125v12c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V16.5a9 9 0 00-9-9z" /></svg></button>
+<div class="accent-gray-400"></div>
 
 <script type="module" src="{{ asset "gist.ts" }}"></script>