pico / feat(prose): live journal #15

closed · opened on 2024-08-01T14:25:45Z by erockAqGV
Help
# add changes to patch request
git format-patch main --stdout | ssh pr.pico.sh pr add 15
# add review to patch request
git format-patch main --stdout | ssh pr.pico.sh pr add --review 15
# remove patchset
ssh pr.pico.sh ps rm ps-x
# checkout all patches
ssh pr.pico.sh pr print 15 | git am -3
# print a diff between the last two patches in a patch request
ssh pr.pico.sh pr diff 15
# accept PR
ssh pr.pico.sh pr accept 15
# close PR
ssh pr.pico.sh pr close 15

Logs

erockAqGV created pr with ps-26 on 2024-08-01T14:25:45Z
erockAqGV added ps-27 on 2024-08-01T15:29:59Z
erockAqGV changed status on 2024-08-01T15:29:59Z {"status":"open"}
erockAqGV changed status on 2024-08-19T20:50:31Z {"status":"closed"}

Patchsets

Diff ↕

feat(prose): live journal

Eric Bower <me@erock.io>
The goal is to be able to use posts as live journals where reader can
receive updates to docs via rss.
 db/db.go           |  1 +
 go.mod             |  1 +
 go.sum             |  8 ++++++++
 prose/api.go       | 48 +++++++++++++++++++++++-----------------------
 prose/scp_hooks.go |  5 +++++
 5 files changed, 39 insertions(+), 24 deletions(-)
  1From 866976a24d3a7963012ffb9cc28bd99364505f71 Mon Sep 17 00:00:00 2001
  2From: Eric Bower <me@erock.io>
  3Date: Thu, 1 Aug 2024 10:24:47 -0400
  4Subject: [PATCH] feat(prose): live journal
  5
  6The goal is to be able to use posts as live journals where reader can
  7receive updates to docs via rss.
  8---
  9 db/db.go           |  1 +
 10 go.mod             |  1 +
 11 go.sum             |  8 ++++++++
 12 prose/api.go       | 48 +++++++++++++++++++++++-----------------------
 13 prose/scp_hooks.go |  5 +++++
 14 5 files changed, 39 insertions(+), 24 deletions(-)
 15
 16diff --git a/db/db.go b/db/db.go
 17index 8cee29c..2c82dc4 100644
 18--- a/db/db.go
 19+++ b/db/db.go
 20@@ -32,6 +32,7 @@ type User struct {
 21 type PostData struct {
 22 	ImgPath    string     `json:"img_path"`
 23 	LastDigest *time.Time `json:"last_digest"`
 24+	Diff       string     `json:"diff"`
 25 }
 26 
 27 // Make the Attrs struct implement the driver.Valuer interface. This method
 28diff --git a/go.mod b/go.mod
 29index b7084ba..549e3ad 100644
 30--- a/go.mod
 31+++ b/go.mod
 32@@ -37,6 +37,7 @@ require (
 33 	github.com/picosh/tunkit v0.0.0-20240709033345-8315d4f3cd0e
 34 	github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
 35 	github.com/sendgrid/sendgrid-go v3.14.0+incompatible
 36+	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
 37 	github.com/simplesurance/go-ip-anonymizer v0.0.0-20200429124537-35a880f8e87d
 38 	github.com/x-way/crawlerdetect v0.2.21
 39 	github.com/yuin/goldmark v1.7.1
 40diff --git a/go.sum b/go.sum
 41index 1980154..2577975 100644
 42--- a/go.sum
 43+++ b/go.sum
 44@@ -158,8 +158,11 @@ github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuV
 45 github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
 46 github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
 47 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
 48+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 49 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 50 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 51+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 52+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 53 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 54 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 55 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
 56@@ -265,6 +268,8 @@ github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekuei
 57 github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
 58 github.com/sendgrid/sendgrid-go v3.14.0+incompatible h1:KDSasSTktAqMJCYClHVE94Fcif2i7P7wzISv1sU6DUA=
 59 github.com/sendgrid/sendgrid-go v3.14.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
 60+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
 61+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
 62 github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
 63 github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
 64 github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
 65@@ -276,6 +281,7 @@ github.com/simplesurance/go-ip-anonymizer v0.0.0-20200429124537-35a880f8e87d/go.
 66 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 67 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 68 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 69+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 70 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 71 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 72 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 73@@ -393,10 +399,12 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
 74 google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
 75 google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
 76 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 77+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 78 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 79 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 80 gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
 81 gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 82+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 83 gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 84 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 85 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 86diff --git a/prose/api.go b/prose/api.go
 87index 1d703d8..7643e0e 100644
 88--- a/prose/api.go
 89+++ b/prose/api.go
 90@@ -160,7 +160,7 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
 91 	}
 92 
 93 	tag := r.URL.Query().Get("tag")
 94-	pager := &db.Pager{Num: 1000, Page: 0}
 95+	pager := &db.Pager{Num: 250, Page: 0}
 96 	var posts []*db.Post
 97 	var p *db.Paginate[*db.Post]
 98 	if tag == "" {
 99@@ -170,6 +170,13 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
100 	}
101 	posts = p.Data
102 
103+	byUpdated := strings.Contains(r.URL.Path, "live")
104+	if byUpdated {
105+		slices.SortFunc(posts, func(a *db.Post, b *db.Post) int {
106+			return b.UpdatedAt.Compare(*a.UpdatedAt)
107+		})
108+	}
109+
110 	if err != nil {
111 		logger.Error(err.Error())
112 		http.Error(w, "could not fetch posts for blog", http.StatusInternalServerError)
113@@ -653,6 +660,13 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
114 	curl := shared.CreateURLFromRequest(cfg, r)
115 	blogUrl := cfg.FullBlogURL(curl, username)
116 
117+	byUpdated := strings.Contains(r.URL.Path, "live")
118+	if byUpdated {
119+		slices.SortFunc(posts, func(a *db.Post, b *db.Post) int {
120+			return b.UpdatedAt.Compare(*a.UpdatedAt)
121+		})
122+	}
123+
124 	feed := &feeds.Feed{
125 		Id:          blogUrl,
126 		Title:       headerTxt.Title,
127@@ -691,12 +705,18 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
128 		}
129 
130 		realUrl := cfg.FullPostURL(curl, post.Username, post.Slug)
131+		feedId := realUrl
132+
133+		if byUpdated {
134+			feedId = fmt.Sprintf("%s:%s", realUrl, post.UpdatedAt.Format(time.RFC3339))
135+		}
136 
137 		item := &feeds.Item{
138-			Id:          realUrl,
139+			Id:          feedId,
140 			Title:       shared.FilenameToTitle(post.Filename, post.Title),
141 			Link:        &feeds.Link{Href: realUrl},
142 			Content:     tpl.String(),
143+			Updated:     *post.UpdatedAt,
144 			Created:     *post.CreatedAt,
145 			Description: post.Description,
146 		}
147@@ -849,36 +869,16 @@ func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
148 		staticRoutes...,
149 	)
150 
151-	routes = append(
152-		routes,
153-		shared.NewRoute("GET", "/rss", rssHandler),
154-		shared.NewRoute("GET", "/rss.xml", rssHandler),
155-		shared.NewRoute("GET", "/atom.xml", rssHandler),
156-		shared.NewRoute("GET", "/feed.xml", rssHandler),
157-
158-		shared.NewRoute("GET", "/([^/]+)", blogHandler),
159-		shared.NewRoute("GET", "/([^/]+)/rss", rssBlogHandler),
160-		shared.NewRoute("GET", "/([^/]+)/rss.xml", rssBlogHandler),
161-		shared.NewRoute("GET", "/([^/]+)/atom.xml", rssBlogHandler),
162-		shared.NewRoute("GET", "/([^/]+)/atom", rssBlogHandler),
163-		shared.NewRoute("GET", "/([^/]+)/blog/index.xml", rssBlogHandler),
164-		shared.NewRoute("GET", "/([^/]+)/feed.xml", rssBlogHandler),
165-		shared.NewRoute("GET", "/([^/]+)/_styles.css", blogStyleHandler),
166-		shared.NewRoute("GET", "/raw/([^/]+)/(.+)", postRawHandler),
167-		shared.NewRoute("GET", "/([^/]+)/(.+)/(.+)", imgs.ImgRequest),
168-		shared.NewRoute("GET", "/([^/]+)/(.+.(?:jpg|jpeg|png|gif|webp|svg))$", imgs.ImgRequest),
169-		shared.NewRoute("GET", "/([^/]+)/i", imgs.ImgsListHandler),
170-		shared.NewRoute("GET", "/([^/]+)/(.+)", postHandler),
171-	)
172-
173 	return routes
174 }
175 
176 func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
177 	routes := []shared.Route{
178 		shared.NewRoute("GET", "/", blogHandler),
179+		shared.NewRoute("GET", "/live", blogHandler),
180 		shared.NewRoute("GET", "/_styles.css", blogStyleHandler),
181 		shared.NewRoute("GET", "/rss", rssBlogHandler),
182+		shared.NewRoute("GET", "/live/rss", rssBlogHandler),
183 		shared.NewRoute("GET", "/rss.xml", rssBlogHandler),
184 		shared.NewRoute("GET", "/atom.xml", rssBlogHandler),
185 		shared.NewRoute("GET", "/feed.xml", rssBlogHandler),
186diff --git a/prose/scp_hooks.go b/prose/scp_hooks.go
187index 577790c..3cb64e6 100644
188--- a/prose/scp_hooks.go
189+++ b/prose/scp_hooks.go
190@@ -10,6 +10,7 @@ import (
191 	"github.com/picosh/pico/db"
192 	"github.com/picosh/pico/filehandlers"
193 	"github.com/picosh/pico/shared"
194+	"github.com/sergi/go-diff/diffmatchpatch"
195 )
196 
197 type MarkdownHooks struct {
198@@ -62,6 +63,10 @@ func (p *MarkdownHooks) FileMeta(s ssh.Session, data *filehandlers.PostMetaData)
199 	data.Tags = parsedText.Tags
200 	data.Description = parsedText.Description
201 
202+	dmp := diffmatchpatch.New()
203+	diffs := dmp.DiffMain(data.Cur.Text, data.Text, false)
204+	data.Data.Diff = dmp.DiffPrettyText(diffs)
205+
206 	if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
207 		data.PublishAt = parsedText.MetaData.PublishAt
208 	}
209
210base-commit: 0bd744d728324024fe7735c06e37a3e228403bc6
211-- 
2122.45.2
213
ps-26 by erockAqGV on 2024-08-01T14:25:45Z
Diff ↕

chore: diff in post

Eric Bower <me@erock.io>
 filehandlers/post_handler.go |  1 +
 prose/api.go                 | 11 +++++++++++
 prose/html/post.page.tmpl    |  9 ++++++++-
 prose/scp_hooks.go           | 31 ++++++++++++++++++++++++++++---
 shared/mdparser.go           |  7 +++++++
 5 files changed, 55 insertions(+), 4 deletions(-)
  1From b82b2f7db8755e20ddfbc119f1f78e5b6f984518 Mon Sep 17 00:00:00 2001
  2From: Eric Bower <me@erock.io>
  3Date: Thu, 1 Aug 2024 11:29:31 -0400
  4Subject: [PATCH 2/2] chore: diff in post
  5
  6---
  7 filehandlers/post_handler.go |  1 +
  8 prose/api.go                 | 11 +++++++++++
  9 prose/html/post.page.tmpl    |  9 ++++++++-
 10 prose/scp_hooks.go           | 31 ++++++++++++++++++++++++++++---
 11 shared/mdparser.go           |  7 +++++++
 12 5 files changed, 55 insertions(+), 4 deletions(-)
 13
 14diff --git a/filehandlers/post_handler.go b/filehandlers/post_handler.go
 15index 4e10b66..30e00cc 100644
 16--- a/filehandlers/post_handler.go
 17+++ b/filehandlers/post_handler.go
 18@@ -139,6 +139,7 @@ func (h *ScpUploadHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
 19 
 20 	if post != nil {
 21 		metadata.Cur = post
 22+		metadata.Data = post.Data
 23 		metadata.Post.PublishAt = post.PublishAt
 24 	}
 25 
 26diff --git a/prose/api.go b/prose/api.go
 27index 7643e0e..60aa52a 100644
 28--- a/prose/api.go
 29+++ b/prose/api.go
 30@@ -83,6 +83,7 @@ type PostPageData struct {
 31 	Footer       template.HTML
 32 	Favicon      template.URL
 33 	Unlisted     bool
 34+	Diff         template.HTML
 35 }
 36 
 37 type TransparencyPageData struct {
 38@@ -399,6 +400,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 39 		favicon = readmeParsed.Favicon
 40 	}
 41 
 42+	diff := ""
 43 	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
 44 	if err == nil {
 45 		parsedText, err := shared.ParseText(post.Text)
 46@@ -414,6 +416,14 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 47 			ogImageCard = parsedText.ImageCard
 48 		}
 49 
 50+		// if parsedText.Live {
 51+		if post.Data.Diff != "" {
 52+			str := "```diff\n" + post.Data.Diff + "\n```"
 53+			diffParsed, _ := shared.ParseText(str)
 54+			diff = diffParsed.Html
 55+		}
 56+		// }
 57+
 58 		// track visit
 59 		view, err := shared.AnalyticsVisitFromRequest(r, user.ID, cfg.Secret)
 60 		if err == nil {
 61@@ -451,6 +461,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 62 			Favicon:      template.URL(favicon),
 63 			Footer:       footerHTML,
 64 			Unlisted:     unlisted,
 65+			Diff:         template.HTML(diff),
 66 		}
 67 	} else {
 68 		// TODO: HACK to support imgs slugs inside prose
 69diff --git a/prose/html/post.page.tmpl b/prose/html/post.page.tmpl
 70index 3811a4c..e667978 100644
 71--- a/prose/html/post.page.tmpl
 72+++ b/prose/html/post.page.tmpl
 73@@ -62,9 +62,16 @@
 74     </div>
 75 </header>
 76 <main>
 77+    {{if .Diff}}
 78+    <details>
 79+      <summary>diff</summary>
 80+      <div class="md">{{.Diff}}</div>
 81+    </details>
 82+    {{end}}
 83+
 84     <article class="md">
 85         {{.Contents}}
 86-        <div id="post-footer">{{.Footer}}</div>
 87+        <div id="post-footer" class="box">{{.Footer}}</div>
 88     </article>
 89 </main>
 90 {{template "footer" .}}
 91diff --git a/prose/scp_hooks.go b/prose/scp_hooks.go
 92index 3cb64e6..707bb61 100644
 93--- a/prose/scp_hooks.go
 94+++ b/prose/scp_hooks.go
 95@@ -1,6 +1,7 @@
 96 package prose
 97 
 98 import (
 99+	"bytes"
100 	"fmt"
101 	"strings"
102 
103@@ -47,6 +48,26 @@ func (p *MarkdownHooks) FileValidate(s ssh.Session, data *filehandlers.PostMetaD
104 	return true, nil
105 }
106 
107+func diffText(diffs []diffmatchpatch.Diff) string {
108+	var buff bytes.Buffer
109+	for _, diff := range diffs {
110+		text := diff.Text
111+
112+		switch diff.Type {
113+		case diffmatchpatch.DiffInsert:
114+			_, _ = buff.WriteString("+")
115+			_, _ = buff.WriteString(text)
116+		case diffmatchpatch.DiffDelete:
117+			_, _ = buff.WriteString("-")
118+			_, _ = buff.WriteString(text)
119+		case diffmatchpatch.DiffEqual:
120+			_, _ = buff.WriteString(text)
121+		}
122+	}
123+
124+	return buff.String()
125+}
126+
127 func (p *MarkdownHooks) FileMeta(s ssh.Session, data *filehandlers.PostMetaData) error {
128 	parsedText, err := shared.ParseText(data.Text)
129 	if err != nil {
130@@ -63,9 +84,13 @@ func (p *MarkdownHooks) FileMeta(s ssh.Session, data *filehandlers.PostMetaData)
131 	data.Tags = parsedText.Tags
132 	data.Description = parsedText.Description
133 
134-	dmp := diffmatchpatch.New()
135-	diffs := dmp.DiffMain(data.Cur.Text, data.Text, false)
136-	data.Data.Diff = dmp.DiffPrettyText(diffs)
137+	if data.Cur.Text == data.Text {
138+	} else {
139+		dmp := diffmatchpatch.New()
140+		diffs := dmp.DiffMain(data.Cur.Text, data.Text, false)
141+		fmt.Println(diffs)
142+		data.Data.Diff = diffText(diffs)
143+	}
144 
145 	if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
146 		data.PublishAt = parsedText.MetaData.PublishAt
147diff --git a/shared/mdparser.go b/shared/mdparser.go
148index 54f234b..1ddf0b1 100644
149--- a/shared/mdparser.go
150+++ b/shared/mdparser.go
151@@ -40,6 +40,7 @@ type MetaData struct {
152 	ImageCard   string
153 	Favicon     string
154 	Hidden      bool
155+	Live        bool
156 }
157 
158 type ParsedText struct {
159@@ -303,6 +304,12 @@ func ParseText(text string) (*ParsedText, error) {
160 	}
161 	parsed.MetaData.Favicon = favicon
162 
163+	live, err := toBool(metaData["live"])
164+	if err != nil {
165+		return &parsed, fmt.Errorf("front-matter field (%s): %w", "live", err)
166+	}
167+	parsed.MetaData.Live = live
168+
169 	var publishAt *time.Time = nil
170 	date, err := toString(metaData["date"])
171 	if err != nil {
172-- 
1732.45.2
174
ps-27 by erockAqGV on 2024-08-01T15:29:59Z

feat(prose): live journal

Eric Bower <me@erock.io> 2024-08-01T14:24:47Z
The goal is to be able to use posts as live journals where reader can
receive updates to docs via rss.
 db/db.go           |  1 +
 go.mod             |  1 +
 go.sum             |  8 ++++++++
 prose/api.go       | 48 +++++++++++++++++++++++-----------------------
 prose/scp_hooks.go |  5 +++++
 5 files changed, 39 insertions(+), 24 deletions(-)
  1From 866976a24d3a7963012ffb9cc28bd99364505f71 Mon Sep 17 00:00:00 2001
  2From: Eric Bower <me@erock.io>
  3Date: Thu, 1 Aug 2024 10:24:47 -0400
  4Subject: [PATCH 1/2] feat(prose): live journal
  5
  6The goal is to be able to use posts as live journals where reader can
  7receive updates to docs via rss.
  8---
  9 db/db.go           |  1 +
 10 go.mod             |  1 +
 11 go.sum             |  8 ++++++++
 12 prose/api.go       | 48 +++++++++++++++++++++++-----------------------
 13 prose/scp_hooks.go |  5 +++++
 14 5 files changed, 39 insertions(+), 24 deletions(-)
 15
 16diff --git a/db/db.go b/db/db.go
 17index 8cee29c..2c82dc4 100644
 18--- a/db/db.go
 19+++ b/db/db.go
 20@@ -32,6 +32,7 @@ type User struct {
 21 type PostData struct {
 22 	ImgPath    string     `json:"img_path"`
 23 	LastDigest *time.Time `json:"last_digest"`
 24+	Diff       string     `json:"diff"`
 25 }
 26 
 27 // Make the Attrs struct implement the driver.Valuer interface. This method
 28diff --git a/go.mod b/go.mod
 29index b7084ba..549e3ad 100644
 30--- a/go.mod
 31+++ b/go.mod
 32@@ -37,6 +37,7 @@ require (
 33 	github.com/picosh/tunkit v0.0.0-20240709033345-8315d4f3cd0e
 34 	github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
 35 	github.com/sendgrid/sendgrid-go v3.14.0+incompatible
 36+	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
 37 	github.com/simplesurance/go-ip-anonymizer v0.0.0-20200429124537-35a880f8e87d
 38 	github.com/x-way/crawlerdetect v0.2.21
 39 	github.com/yuin/goldmark v1.7.1
 40diff --git a/go.sum b/go.sum
 41index 1980154..2577975 100644
 42--- a/go.sum
 43+++ b/go.sum
 44@@ -158,8 +158,11 @@ github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuV
 45 github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
 46 github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
 47 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
 48+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 49 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 50 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 51+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 52+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 53 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 54 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 55 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
 56@@ -265,6 +268,8 @@ github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekuei
 57 github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
 58 github.com/sendgrid/sendgrid-go v3.14.0+incompatible h1:KDSasSTktAqMJCYClHVE94Fcif2i7P7wzISv1sU6DUA=
 59 github.com/sendgrid/sendgrid-go v3.14.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
 60+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
 61+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
 62 github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
 63 github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
 64 github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
 65@@ -276,6 +281,7 @@ github.com/simplesurance/go-ip-anonymizer v0.0.0-20200429124537-35a880f8e87d/go.
 66 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 67 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 68 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 69+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 70 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 71 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 72 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 73@@ -393,10 +399,12 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
 74 google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
 75 google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
 76 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 77+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 78 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 79 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 80 gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
 81 gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 82+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 83 gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 84 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 85 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 86diff --git a/prose/api.go b/prose/api.go
 87index 1d703d8..7643e0e 100644
 88--- a/prose/api.go
 89+++ b/prose/api.go
 90@@ -160,7 +160,7 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
 91 	}
 92 
 93 	tag := r.URL.Query().Get("tag")
 94-	pager := &db.Pager{Num: 1000, Page: 0}
 95+	pager := &db.Pager{Num: 250, Page: 0}
 96 	var posts []*db.Post
 97 	var p *db.Paginate[*db.Post]
 98 	if tag == "" {
 99@@ -170,6 +170,13 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
100 	}
101 	posts = p.Data
102 
103+	byUpdated := strings.Contains(r.URL.Path, "live")
104+	if byUpdated {
105+		slices.SortFunc(posts, func(a *db.Post, b *db.Post) int {
106+			return b.UpdatedAt.Compare(*a.UpdatedAt)
107+		})
108+	}
109+
110 	if err != nil {
111 		logger.Error(err.Error())
112 		http.Error(w, "could not fetch posts for blog", http.StatusInternalServerError)
113@@ -653,6 +660,13 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
114 	curl := shared.CreateURLFromRequest(cfg, r)
115 	blogUrl := cfg.FullBlogURL(curl, username)
116 
117+	byUpdated := strings.Contains(r.URL.Path, "live")
118+	if byUpdated {
119+		slices.SortFunc(posts, func(a *db.Post, b *db.Post) int {
120+			return b.UpdatedAt.Compare(*a.UpdatedAt)
121+		})
122+	}
123+
124 	feed := &feeds.Feed{
125 		Id:          blogUrl,
126 		Title:       headerTxt.Title,
127@@ -691,12 +705,18 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
128 		}
129 
130 		realUrl := cfg.FullPostURL(curl, post.Username, post.Slug)
131+		feedId := realUrl
132+
133+		if byUpdated {
134+			feedId = fmt.Sprintf("%s:%s", realUrl, post.UpdatedAt.Format(time.RFC3339))
135+		}
136 
137 		item := &feeds.Item{
138-			Id:          realUrl,
139+			Id:          feedId,
140 			Title:       shared.FilenameToTitle(post.Filename, post.Title),
141 			Link:        &feeds.Link{Href: realUrl},
142 			Content:     tpl.String(),
143+			Updated:     *post.UpdatedAt,
144 			Created:     *post.CreatedAt,
145 			Description: post.Description,
146 		}
147@@ -849,36 +869,16 @@ func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
148 		staticRoutes...,
149 	)
150 
151-	routes = append(
152-		routes,
153-		shared.NewRoute("GET", "/rss", rssHandler),
154-		shared.NewRoute("GET", "/rss.xml", rssHandler),
155-		shared.NewRoute("GET", "/atom.xml", rssHandler),
156-		shared.NewRoute("GET", "/feed.xml", rssHandler),
157-
158-		shared.NewRoute("GET", "/([^/]+)", blogHandler),
159-		shared.NewRoute("GET", "/([^/]+)/rss", rssBlogHandler),
160-		shared.NewRoute("GET", "/([^/]+)/rss.xml", rssBlogHandler),
161-		shared.NewRoute("GET", "/([^/]+)/atom.xml", rssBlogHandler),
162-		shared.NewRoute("GET", "/([^/]+)/atom", rssBlogHandler),
163-		shared.NewRoute("GET", "/([^/]+)/blog/index.xml", rssBlogHandler),
164-		shared.NewRoute("GET", "/([^/]+)/feed.xml", rssBlogHandler),
165-		shared.NewRoute("GET", "/([^/]+)/_styles.css", blogStyleHandler),
166-		shared.NewRoute("GET", "/raw/([^/]+)/(.+)", postRawHandler),
167-		shared.NewRoute("GET", "/([^/]+)/(.+)/(.+)", imgs.ImgRequest),
168-		shared.NewRoute("GET", "/([^/]+)/(.+.(?:jpg|jpeg|png|gif|webp|svg))$", imgs.ImgRequest),
169-		shared.NewRoute("GET", "/([^/]+)/i", imgs.ImgsListHandler),
170-		shared.NewRoute("GET", "/([^/]+)/(.+)", postHandler),
171-	)
172-
173 	return routes
174 }
175 
176 func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
177 	routes := []shared.Route{
178 		shared.NewRoute("GET", "/", blogHandler),
179+		shared.NewRoute("GET", "/live", blogHandler),
180 		shared.NewRoute("GET", "/_styles.css", blogStyleHandler),
181 		shared.NewRoute("GET", "/rss", rssBlogHandler),
182+		shared.NewRoute("GET", "/live/rss", rssBlogHandler),
183 		shared.NewRoute("GET", "/rss.xml", rssBlogHandler),
184 		shared.NewRoute("GET", "/atom.xml", rssBlogHandler),
185 		shared.NewRoute("GET", "/feed.xml", rssBlogHandler),
186diff --git a/prose/scp_hooks.go b/prose/scp_hooks.go
187index 577790c..3cb64e6 100644
188--- a/prose/scp_hooks.go
189+++ b/prose/scp_hooks.go
190@@ -10,6 +10,7 @@ import (
191 	"github.com/picosh/pico/db"
192 	"github.com/picosh/pico/filehandlers"
193 	"github.com/picosh/pico/shared"
194+	"github.com/sergi/go-diff/diffmatchpatch"
195 )
196 
197 type MarkdownHooks struct {
198@@ -62,6 +63,10 @@ func (p *MarkdownHooks) FileMeta(s ssh.Session, data *filehandlers.PostMetaData)
199 	data.Tags = parsedText.Tags
200 	data.Description = parsedText.Description
201 
202+	dmp := diffmatchpatch.New()
203+	diffs := dmp.DiffMain(data.Cur.Text, data.Text, false)
204+	data.Data.Diff = dmp.DiffPrettyText(diffs)
205+
206 	if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
207 		data.PublishAt = parsedText.MetaData.PublishAt
208 	}
209
210base-commit: 0bd744d728324024fe7735c06e37a3e228403bc6
211-- 
2122.45.2
213

chore: diff in post

Eric Bower <me@erock.io> 2024-08-01T15:29:31Z
 filehandlers/post_handler.go |  1 +
 prose/api.go                 | 11 +++++++++++
 prose/html/post.page.tmpl    |  9 ++++++++-
 prose/scp_hooks.go           | 31 ++++++++++++++++++++++++++++---
 shared/mdparser.go           |  7 +++++++
 5 files changed, 55 insertions(+), 4 deletions(-)
  1From b82b2f7db8755e20ddfbc119f1f78e5b6f984518 Mon Sep 17 00:00:00 2001
  2From: Eric Bower <me@erock.io>
  3Date: Thu, 1 Aug 2024 11:29:31 -0400
  4Subject: [PATCH 2/2] chore: diff in post
  5
  6---
  7 filehandlers/post_handler.go |  1 +
  8 prose/api.go                 | 11 +++++++++++
  9 prose/html/post.page.tmpl    |  9 ++++++++-
 10 prose/scp_hooks.go           | 31 ++++++++++++++++++++++++++++---
 11 shared/mdparser.go           |  7 +++++++
 12 5 files changed, 55 insertions(+), 4 deletions(-)
 13
 14diff --git a/filehandlers/post_handler.go b/filehandlers/post_handler.go
 15index 4e10b66..30e00cc 100644
 16--- a/filehandlers/post_handler.go
 17+++ b/filehandlers/post_handler.go
 18@@ -139,6 +139,7 @@ func (h *ScpUploadHandler) Write(s ssh.Session, entry *utils.FileEntry) (string,
 19 
 20 	if post != nil {
 21 		metadata.Cur = post
 22+		metadata.Data = post.Data
 23 		metadata.Post.PublishAt = post.PublishAt
 24 	}
 25 
 26diff --git a/prose/api.go b/prose/api.go
 27index 7643e0e..60aa52a 100644
 28--- a/prose/api.go
 29+++ b/prose/api.go
 30@@ -83,6 +83,7 @@ type PostPageData struct {
 31 	Footer       template.HTML
 32 	Favicon      template.URL
 33 	Unlisted     bool
 34+	Diff         template.HTML
 35 }
 36 
 37 type TransparencyPageData struct {
 38@@ -399,6 +400,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 39 		favicon = readmeParsed.Favicon
 40 	}
 41 
 42+	diff := ""
 43 	post, err := dbpool.FindPostWithSlug(slug, user.ID, cfg.Space)
 44 	if err == nil {
 45 		parsedText, err := shared.ParseText(post.Text)
 46@@ -414,6 +416,14 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 47 			ogImageCard = parsedText.ImageCard
 48 		}
 49 
 50+		// if parsedText.Live {
 51+		if post.Data.Diff != "" {
 52+			str := "```diff\n" + post.Data.Diff + "\n```"
 53+			diffParsed, _ := shared.ParseText(str)
 54+			diff = diffParsed.Html
 55+		}
 56+		// }
 57+
 58 		// track visit
 59 		view, err := shared.AnalyticsVisitFromRequest(r, user.ID, cfg.Secret)
 60 		if err == nil {
 61@@ -451,6 +461,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 62 			Favicon:      template.URL(favicon),
 63 			Footer:       footerHTML,
 64 			Unlisted:     unlisted,
 65+			Diff:         template.HTML(diff),
 66 		}
 67 	} else {
 68 		// TODO: HACK to support imgs slugs inside prose
 69diff --git a/prose/html/post.page.tmpl b/prose/html/post.page.tmpl
 70index 3811a4c..e667978 100644
 71--- a/prose/html/post.page.tmpl
 72+++ b/prose/html/post.page.tmpl
 73@@ -62,9 +62,16 @@
 74     </div>
 75 </header>
 76 <main>
 77+    {{if .Diff}}
 78+    <details>
 79+      <summary>diff</summary>
 80+      <div class="md">{{.Diff}}</div>
 81+    </details>
 82+    {{end}}
 83+
 84     <article class="md">
 85         {{.Contents}}
 86-        <div id="post-footer">{{.Footer}}</div>
 87+        <div id="post-footer" class="box">{{.Footer}}</div>
 88     </article>
 89 </main>
 90 {{template "footer" .}}
 91diff --git a/prose/scp_hooks.go b/prose/scp_hooks.go
 92index 3cb64e6..707bb61 100644
 93--- a/prose/scp_hooks.go
 94+++ b/prose/scp_hooks.go
 95@@ -1,6 +1,7 @@
 96 package prose
 97 
 98 import (
 99+	"bytes"
100 	"fmt"
101 	"strings"
102 
103@@ -47,6 +48,26 @@ func (p *MarkdownHooks) FileValidate(s ssh.Session, data *filehandlers.PostMetaD
104 	return true, nil
105 }
106 
107+func diffText(diffs []diffmatchpatch.Diff) string {
108+	var buff bytes.Buffer
109+	for _, diff := range diffs {
110+		text := diff.Text
111+
112+		switch diff.Type {
113+		case diffmatchpatch.DiffInsert:
114+			_, _ = buff.WriteString("+")
115+			_, _ = buff.WriteString(text)
116+		case diffmatchpatch.DiffDelete:
117+			_, _ = buff.WriteString("-")
118+			_, _ = buff.WriteString(text)
119+		case diffmatchpatch.DiffEqual:
120+			_, _ = buff.WriteString(text)
121+		}
122+	}
123+
124+	return buff.String()
125+}
126+
127 func (p *MarkdownHooks) FileMeta(s ssh.Session, data *filehandlers.PostMetaData) error {
128 	parsedText, err := shared.ParseText(data.Text)
129 	if err != nil {
130@@ -63,9 +84,13 @@ func (p *MarkdownHooks) FileMeta(s ssh.Session, data *filehandlers.PostMetaData)
131 	data.Tags = parsedText.Tags
132 	data.Description = parsedText.Description
133 
134-	dmp := diffmatchpatch.New()
135-	diffs := dmp.DiffMain(data.Cur.Text, data.Text, false)
136-	data.Data.Diff = dmp.DiffPrettyText(diffs)
137+	if data.Cur.Text == data.Text {
138+	} else {
139+		dmp := diffmatchpatch.New()
140+		diffs := dmp.DiffMain(data.Cur.Text, data.Text, false)
141+		fmt.Println(diffs)
142+		data.Data.Diff = diffText(diffs)
143+	}
144 
145 	if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
146 		data.PublishAt = parsedText.MetaData.PublishAt
147diff --git a/shared/mdparser.go b/shared/mdparser.go
148index 54f234b..1ddf0b1 100644
149--- a/shared/mdparser.go
150+++ b/shared/mdparser.go
151@@ -40,6 +40,7 @@ type MetaData struct {
152 	ImageCard   string
153 	Favicon     string
154 	Hidden      bool
155+	Live        bool
156 }
157 
158 type ParsedText struct {
159@@ -303,6 +304,12 @@ func ParseText(text string) (*ParsedText, error) {
160 	}
161 	parsed.MetaData.Favicon = favicon
162 
163+	live, err := toBool(metaData["live"])
164+	if err != nil {
165+		return &parsed, fmt.Errorf("front-matter field (%s): %w", "live", err)
166+	}
167+	parsed.MetaData.Live = live
168+
169 	var publishAt *time.Time = nil
170 	date, err := toString(metaData["date"])
171 	if err != nil {
172-- 
1732.45.2
174