Add CLRI/TLRI handling

This commit is contained in:
Ben Visness 2021-07-23 14:00:37 -05:00
parent 94457aeb93
commit 9b9d467ce4
17 changed files with 199 additions and 54 deletions

View File

@ -4690,7 +4690,11 @@ code, .code {
padding-top: 0; padding-top: 0;
padding-bottom: 0; } padding-bottom: 0; }
.pv1, .optionbar .options > * { .pv1,
.optionbar .options button,
.optionbar .options .button,
.optionbar .options input[type=button],
.optionbar .options input[type=submit] {
padding-top: 0.25rem; padding-top: 0.25rem;
padding-bottom: 0.25rem; } padding-bottom: 0.25rem; }
@ -4730,7 +4734,11 @@ input[type=submit] {
padding-left: 0.25rem; padding-left: 0.25rem;
padding-right: 0.25rem; } padding-right: 0.25rem; }
.ph2, .optionbar .options > *, .pagination .button { .ph2,
.optionbar .options button,
.optionbar .options .button,
.optionbar .options input[type=button],
.optionbar .options input[type=submit], .pagination .button {
padding-left: 0.5rem; padding-left: 0.5rem;
padding-right: 0.5rem; } padding-right: 0.5rem; }
@ -5029,7 +5037,11 @@ input[type=submit] {
.pv1-ns { .pv1-ns {
padding-top: 0.25rem; padding-top: 0.25rem;
padding-bottom: 0.25rem; } padding-bottom: 0.25rem; }
.pv2-ns, .optionbar .options > * { .pv2-ns,
.optionbar .options button,
.optionbar .options .button,
.optionbar .options input[type=button],
.optionbar .options input[type=submit] {
padding-top: 0.5rem; padding-top: 0.5rem;
padding-bottom: 0.5rem; } padding-bottom: 0.5rem; }
.pv3-ns, header .menu-bar .items a { .pv3-ns, header .menu-bar .items a {
@ -7741,13 +7753,16 @@ header {
.optionbar .options { .optionbar .options {
flex-direction: row; } } flex-direction: row; } }
.optionbar button, .optionbar .options button,
.optionbar .button, .optionbar .options .button,
.optionbar input[type=button], .optionbar .options input[type=button],
.optionbar input[type=submit] { .optionbar .options input[type=submit] {
display: inline-flex;
border: none; border: none;
background: none; background: none;
font-weight: normal; } font-weight: normal;
font-family: inherit;
line-height: inherit; }
.optionbar .group { .optionbar .group {
display: inline-block; display: inline-block;
height: 100%; height: 100%;
@ -8447,9 +8462,12 @@ input[type=submit] {
.button.lite, .button.lite,
input[type=button].lite, input[type=button].lite,
input[type=submit].lite { input[type=submit].lite {
display: inline-flex;
border: none; border: none;
background: none; background: none;
font-weight: normal; } font-weight: normal;
font-family: inherit;
line-height: inherit; }
button.inline-button, button.inline-button,
.button.inline-button, .button.inline-button,

View File

@ -395,8 +395,8 @@ func TestPublic(t *testing.T) {
AssertRegexMatch(t, BuildUserFile("mylogo.png"), RegexPublic, nil) AssertRegexMatch(t, BuildUserFile("mylogo.png"), RegexPublic, nil)
} }
func TestMarkRead(t *testing.T) { func TestForumCategoryMarkRead(t *testing.T) {
AssertRegexMatch(t, BuildMarkRead(5), RegexMarkRead, map[string]string{"catid": "5"}) AssertRegexMatch(t, BuildForumCategoryMarkRead(5), RegexForumCategoryMarkRead, map[string]string{"catid": "5"})
} }
func AssertSubdomain(t *testing.T, fullUrl string, expectedSubdomain string) { func AssertSubdomain(t *testing.T, fullUrl string, expectedSubdomain string) {

View File

@ -803,17 +803,17 @@ func BuildUserFile(filepath string) string {
* Other * Other
*/ */
var RegexMarkRead = regexp.MustCompile(`^/_markread/(?P<catid>\d+)$`) var RegexForumCategoryMarkRead = regexp.MustCompile(`^/markread/(?P<catid>\d+)$`)
// NOTE(asaf): categoryId == 0 means ALL CATEGORIES // NOTE(asaf): categoryId == 0 means ALL CATEGORIES
func BuildMarkRead(categoryId int) string { func BuildForumCategoryMarkRead(categoryId int) string {
defer CatchPanic() defer CatchPanic()
if categoryId < 0 { if categoryId < 0 {
panic(oops.New(nil, "Invalid category ID (%d), must be >= 0", categoryId)) panic(oops.New(nil, "Invalid category ID (%d), must be >= 0", categoryId))
} }
var builder strings.Builder var builder strings.Builder
builder.WriteString("/_markread/") builder.WriteString("/markread/")
builder.WriteString(strconv.Itoa(categoryId)) builder.WriteString(strconv.Itoa(categoryId))
return Url(builder.String(), nil) return Url(builder.String(), nil)

View File

@ -25,7 +25,7 @@ var PlaintextMarkdown = goldmark.New(
goldmark.WithRenderer(plaintextRenderer{}), goldmark.WithRenderer(plaintextRenderer{}),
) )
func ParsePostInput(source string, md goldmark.Markdown) string { func ParseMarkdown(source string, md goldmark.Markdown) string {
var buf bytes.Buffer var buf bytes.Buffer
if err := md.Convert([]byte(source), &buf); err != nil { if err := md.Convert([]byte(source), &buf); err != nil {
panic(err) panic(err)

View File

@ -10,14 +10,14 @@ import (
func TestMarkdown(t *testing.T) { func TestMarkdown(t *testing.T) {
t.Run("fenced code blocks", func(t *testing.T) { t.Run("fenced code blocks", func(t *testing.T) {
t.Run("multiple lines", func(t *testing.T) { t.Run("multiple lines", func(t *testing.T) {
html := ParsePostInput("```\nmultiple lines\n\tof code\n```", RealMarkdown) html := ParseMarkdown("```\nmultiple lines\n\tof code\n```", RealMarkdown)
t.Log(html) t.Log(html)
assert.Equal(t, 1, strings.Count(html, "<pre")) assert.Equal(t, 1, strings.Count(html, "<pre"))
assert.Contains(t, html, `class="hmn-code"`) assert.Contains(t, html, `class="hmn-code"`)
assert.Contains(t, html, "multiple lines\n\tof code") assert.Contains(t, html, "multiple lines\n\tof code")
}) })
t.Run("multiple lines with language", func(t *testing.T) { t.Run("multiple lines with language", func(t *testing.T) {
html := ParsePostInput("```go\nfunc main() {\n\tfmt.Println(\"Hello, world!\")\n}\n```", RealMarkdown) html := ParseMarkdown("```go\nfunc main() {\n\tfmt.Println(\"Hello, world!\")\n}\n```", RealMarkdown)
t.Log(html) t.Log(html)
assert.Equal(t, 1, strings.Count(html, "<pre")) assert.Equal(t, 1, strings.Count(html, "<pre"))
assert.Contains(t, html, `class="hmn-code"`) assert.Contains(t, html, `class="hmn-code"`)
@ -30,7 +30,7 @@ func TestMarkdown(t *testing.T) {
func TestBBCode(t *testing.T) { func TestBBCode(t *testing.T) {
t.Run("[code]", func(t *testing.T) { t.Run("[code]", func(t *testing.T) {
t.Run("one line", func(t *testing.T) { t.Run("one line", func(t *testing.T) {
html := ParsePostInput("[code]Just some code, you know?[/code]", RealMarkdown) html := ParseMarkdown("[code]Just some code, you know?[/code]", RealMarkdown)
t.Log(html) t.Log(html)
assert.Equal(t, 1, strings.Count(html, "<pre")) assert.Equal(t, 1, strings.Count(html, "<pre"))
assert.Contains(t, html, `class="hmn-code"`) assert.Contains(t, html, `class="hmn-code"`)
@ -41,7 +41,7 @@ func TestBBCode(t *testing.T) {
Multiline code Multiline code
with an indent with an indent
[/code]` [/code]`
html := ParsePostInput(bbcode, RealMarkdown) html := ParseMarkdown(bbcode, RealMarkdown)
t.Log(html) t.Log(html)
assert.Equal(t, 1, strings.Count(html, "<pre")) assert.Equal(t, 1, strings.Count(html, "<pre"))
assert.Contains(t, html, `class="hmn-code"`) assert.Contains(t, html, `class="hmn-code"`)
@ -54,7 +54,7 @@ func main() {
fmt.Println("Hello, world!") fmt.Println("Hello, world!")
} }
[/code]` [/code]`
html := ParsePostInput(bbcode, RealMarkdown) html := ParseMarkdown(bbcode, RealMarkdown)
t.Log(html) t.Log(html)
assert.Equal(t, 1, strings.Count(html, "<pre")) assert.Equal(t, 1, strings.Count(html, "<pre"))
assert.Contains(t, html, "Println") assert.Contains(t, html, "Println")
@ -66,7 +66,7 @@ func main() {
func TestSharlock(t *testing.T) { func TestSharlock(t *testing.T) {
t.Skipf("This doesn't pass right now because parts of Sharlock's original source read as indented code blocks, or depend on different line break behavior.") t.Skipf("This doesn't pass right now because parts of Sharlock's original source read as indented code blocks, or depend on different line break behavior.")
t.Run("sanity check", func(t *testing.T) { t.Run("sanity check", func(t *testing.T) {
result := ParsePostInput(sharlock, RealMarkdown) result := ParseMarkdown(sharlock, RealMarkdown)
for _, line := range strings.Split(result, "\n") { for _, line := range strings.Split(result, "\n") {
assert.NotContains(t, line, "[b]") assert.NotContains(t, line, "[b]")
@ -85,6 +85,6 @@ func TestSharlock(t *testing.T) {
func BenchmarkSharlock(b *testing.B) { func BenchmarkSharlock(b *testing.B) {
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
ParsePostInput(sharlock, RealMarkdown) ParseMarkdown(sharlock, RealMarkdown)
} }
} }

View File

@ -8,7 +8,7 @@ import (
func main() { func main() {
js.Global().Set("parseMarkdown", js.FuncOf(func(this js.Value, args []js.Value) interface{} { js.Global().Set("parseMarkdown", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
return parsing.ParsePostInput(args[0].String(), parsing.PreviewMarkdown) return parsing.ParseMarkdown(args[0].String(), parsing.PreviewMarkdown)
})) }))
var done chan bool var done chan bool

View File

@ -702,15 +702,14 @@ footer {
flex-direction: row; flex-direction: row;
} }
& > * { & {
#{$buttons} {
@include lite-button;
@extend .ph2; @extend .ph2;
@extend .pv1; @extend .pv1;
@extend .pv2-ns; @extend .pv2-ns;
} }
} }
#{$buttons} {
@include lite-button;
} }
.group { .group {

View File

@ -45,9 +45,12 @@ will throw an error.
} }
@mixin lite-button { @mixin lite-button {
display: inline-flex;
border: none; border: none;
background: none; background: none;
font-weight: normal; font-weight: normal;
font-family: inherit;
line-height: inherit;
} }
$buttons: " $buttons: "

View File

@ -10,9 +10,12 @@
<div class="content-block"> <div class="content-block">
<div class="optionbar"> <div class="optionbar">
<div class="options"> <div class="options">
<a class="button" href="{{ .AtomFeedUrl }}"><span class="icon big">4</span> RSS Feed</span></a> <a class="button" href="{{ .AtomFeedUrl }}"><span class="icon big pr1">4</span> RSS Feed</span></a>
{{ if .User }} {{ if .User }}
<a class="button" href="{{ .MarkAllReadUrl }}"><span class="big">&#x2713;</span> Mark all posts on site as read</a> <form method="POST" action="{{ .MarkAllReadUrl }}">
{{ csrftoken .Session }}
<button type="submit"><span class="big pr1">&#x2713;</span> Mark all posts on site as read</button>
</form>
{{ end }} {{ end }}
</div> </div>
<div class="options"> <div class="options">

View File

@ -35,9 +35,11 @@
{{ define "forum_category_options" }} {{ define "forum_category_options" }}
<div class="options"> <div class="options">
{{ if .User }} {{ if .User }}
<a class="button new-thread" href="{{ .NewThreadUrl }}"><span class="big">+</span> New Thread</a> <a class="button new-thread" href="{{ .NewThreadUrl }}"><span class="big pr1">+</span> New Thread</a>
{{/* TODO(asaf): Mark read should probably be a POST, since it's destructive and we would probably want CSRF for it */}} <form method="POST" action="{{ .MarkReadUrl }}">
<a class="button" href="{{ .MarkReadUrl }}"><span class="big">&#x2713;</span> Mark threads here as read</a> {{ csrftoken .Session }}
<button type="submit"><span class="big pr1">&#x2713;</span> Mark threads here as read</button>
</form>
{{ else }} {{ else }}
<a class="button" href="{{ .LoginPageUrl }}">Log in to post a new thread</a> <a class="button" href="{{ .LoginPageUrl }}">Log in to post a new thread</a>
{{ end }} {{ end }}

View File

@ -3,9 +3,13 @@
{{ define "content" }} {{ define "content" }}
<div class="content-block"> <div class="content-block">
<div class="optionbar"> <div class="optionbar">
<div class="options">
<a class="button" href="{{ .CategoryUrl }}">&larr; Back to index</a> <a class="button" href="{{ .CategoryUrl }}">&larr; Back to index</a>
</div>
<div class="options">
{{ template "pagination.html" .Pagination }} {{ template "pagination.html" .Pagination }}
</div> </div>
</div>
{{ range .Posts }} {{ range .Posts }}
<div class="post background-even pa3"> <div class="post background-even pa3">
<div class="fl w-100 w-25-l pt3 pa3-l flex tc-l"> <div class="fl w-100 w-25-l pt3 pa3-l flex tc-l">

View File

@ -1,5 +1,5 @@
{{- /*gotype: git.handmade.network/hmn/hmn/src/templates.Pagination*/ -}} {{- /*gotype: git.handmade.network/hmn/hmn/src/templates.Pagination*/ -}}
<div class="pagination"> <div class="pagination flex">
{{ if gt .Current 1 }} {{ if gt .Current 1 }}
<a class="button" href="{{ .PreviousUrl }}">Prev</a> <a class="button" href="{{ .PreviousUrl }}">Prev</a>
{{ end }} {{ end }}

View File

@ -98,7 +98,7 @@ func Feed(c *RequestContext) ResponseData {
BaseData: baseData, BaseData: baseData,
AtomFeedUrl: hmnurl.BuildAtomFeed(), AtomFeedUrl: hmnurl.BuildAtomFeed(),
MarkAllReadUrl: hmnurl.BuildMarkRead(0), MarkAllReadUrl: hmnurl.BuildForumCategoryMarkRead(0),
Posts: posts, Posts: posts,
Pagination: pagination, Pagination: pagination,
}, c.Perf) }, c.Perf)

View File

@ -279,7 +279,7 @@ func ForumCategory(c *RequestContext) ResponseData {
res.MustWriteTemplate("forum_category.html", forumCategoryData{ res.MustWriteTemplate("forum_category.html", forumCategoryData{
BaseData: baseData, BaseData: baseData,
NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs, false), NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs, false),
MarkReadUrl: hmnurl.BuildMarkRead(currentCatId), MarkReadUrl: hmnurl.BuildForumCategoryMarkRead(currentCatId),
Threads: threads, Threads: threads,
Pagination: templates.Pagination{ Pagination: templates.Pagination{
Current: page, Current: page,
@ -295,6 +295,103 @@ func ForumCategory(c *RequestContext) ResponseData {
return res return res
} }
func ForumCategoryMarkRead(c *RequestContext) ResponseData {
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
catId, err := strconv.Atoi(c.PathParams["catid"])
if err != nil {
return FourOhFour(c)
}
tx, err := c.Conn.Begin(c.Context())
if err != nil {
panic(err)
}
defer tx.Rollback(c.Context())
// TODO(ben): Rework this logic when we rework blogs, threads, etc.
catIds := []int{catId}
if catId == 0 {
// Select all categories
type catIdResult struct {
CatID int `db:"id"`
}
cats, err := db.Query(c.Context(), tx, catIdResult{},
`
SELECT $columns
FROM handmade_category
WHERE kind = ANY ($1)
`,
[]models.CategoryKind{models.CatKindBlog, models.CatKindForum, models.CatKindWiki, models.CatKindLibraryResource},
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch category IDs for CLRI"))
}
catIdResults := cats.ToSlice()
catIds = make([]int, len(catIdResults))
for i, res := range catIdResults {
catIds[i] = res.(*catIdResult).CatID
}
}
c.Perf.StartBlock("SQL", "Update CLRIs")
_, err = tx.Exec(c.Context(),
`
INSERT INTO handmade_categorylastreadinfo (category_id, user_id, lastread)
SELECT id, $2, $3
FROM handmade_category
WHERE id = ANY ($1)
ON CONFLICT (category_id, user_id) DO UPDATE
SET lastread = EXCLUDED.lastread
`,
catIds,
c.CurrentUser.ID,
time.Now(),
)
c.Perf.EndBlock()
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update forum clris"))
}
c.Perf.StartBlock("SQL", "Delete TLRIs")
_, err = tx.Exec(c.Context(),
`
DELETE FROM handmade_threadlastreadinfo
WHERE
user_id = $2
AND thread_id IN (
SELECT id
FROM handmade_thread
WHERE
category_id = ANY ($1)
)
`,
catIds,
c.CurrentUser.ID,
)
c.Perf.EndBlock()
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete unnecessary tlris"))
}
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit CLRI/TLRI updates"))
}
var redirUrl string
if catId == 0 {
redirUrl = hmnurl.BuildFeed()
} else {
redirUrl = hmnurl.BuildForumCategory(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(catId), 1)
}
return c.Redirect(redirUrl, http.StatusSeeOther)
}
type forumThreadData struct { type forumThreadData struct {
templates.BaseData templates.BaseData
@ -435,6 +532,24 @@ func ForumThread(c *RequestContext) ResponseData {
posts = append(posts, post) posts = append(posts, post)
} }
// Update thread last read info
c.Perf.StartBlock("SQL", "Update TLRI")
_, err = c.Conn.Exec(c.Context(),
`
INSERT INTO handmade_threadlastreadinfo (thread_id, user_id, lastread)
VALUES ($1, $2, $3)
ON CONFLICT (thread_id, user_id) DO UPDATE
SET lastread = EXCLUDED.lastread
`,
threadId,
c.CurrentUser.ID,
time.Now(),
)
c.Perf.EndBlock()
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update forum tlri"))
}
baseData := getBaseData(c) baseData := getBaseData(c)
baseData.Title = thread.Title baseData.Title = thread.Title
// TODO(asaf): Set breadcrumbs // TODO(asaf): Set breadcrumbs
@ -1132,11 +1247,11 @@ func createNewForumPostAndVersion(ctx context.Context, tx pgx.Tx, catId, threadI
} }
func createForumPostVersion(ctx context.Context, tx pgx.Tx, postId int, unparsedContent string, ipString string, editReason string, editorId *int) (versionId int) { func createForumPostVersion(ctx context.Context, tx pgx.Tx, postId int, unparsedContent string, ipString string, editReason string, editorId *int) (versionId int) {
parsed := parsing.ParsePostInput(unparsedContent, parsing.RealMarkdown) parsed := parsing.ParseMarkdown(unparsedContent, parsing.RealMarkdown)
ip := net.ParseIP(ipString) ip := net.ParseIP(ipString)
const previewMaxLength = 100 const previewMaxLength = 100
parsedPlaintext := parsing.ParsePostInput(unparsedContent, parsing.PlaintextMarkdown) parsedPlaintext := parsing.ParseMarkdown(unparsedContent, parsing.PlaintextMarkdown)
preview := parsedPlaintext preview := parsedPlaintext
if len(preview) > previewMaxLength-1 { if len(preview) > previewMaxLength-1 {
preview = preview[:previewMaxLength-1] + "…" preview = preview[:previewMaxLength-1] + "…"

View File

@ -484,7 +484,7 @@ func PodcastEpisodeSubmit(c *RequestContext) ResponseData {
} }
c.Perf.StartBlock("MARKDOWN", "Parsing description") c.Perf.StartBlock("MARKDOWN", "Parsing description")
descriptionRendered := parsing.ParsePostInput(description, parsing.RealMarkdown) descriptionRendered := parsing.ParseMarkdown(description, parsing.RealMarkdown)
c.Perf.EndBlock() c.Perf.EndBlock()
guidStr := "" guidStr := ""

View File

@ -157,13 +157,14 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
mainRoutes.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit))) mainRoutes.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit)))
mainRoutes.GET(hmnurl.RegexForumThread, ForumThread) mainRoutes.GET(hmnurl.RegexForumThread, ForumThread)
mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory) mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory)
mainRoutes.POST(hmnurl.RegexForumCategoryMarkRead, authMiddleware(csrfMiddleware(ForumCategoryMarkRead)))
mainRoutes.GET(hmnurl.RegexForumPost, ForumPostRedirect) mainRoutes.GET(hmnurl.RegexForumPost, ForumPostRedirect)
mainRoutes.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply)) mainRoutes.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply))
mainRoutes.POST(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReplySubmit)) mainRoutes.POST(hmnurl.RegexForumPostReply, authMiddleware(csrfMiddleware(ForumPostReplySubmit)))
mainRoutes.GET(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEdit)) mainRoutes.GET(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEdit))
mainRoutes.POST(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEditSubmit)) mainRoutes.POST(hmnurl.RegexForumPostEdit, authMiddleware(csrfMiddleware(ForumPostEditSubmit)))
mainRoutes.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete)) mainRoutes.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete))
mainRoutes.POST(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDeleteSubmit)) mainRoutes.POST(hmnurl.RegexForumPostDelete, authMiddleware(csrfMiddleware(ForumPostDeleteSubmit)))
mainRoutes.GET(hmnurl.RegexPodcast, PodcastIndex) mainRoutes.GET(hmnurl.RegexPodcast, PodcastIndex)
mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit) mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit)