Add forum post editing

This commit is contained in:
Ben Visness 2021-07-21 20:41:23 -05:00
parent 1ccf715c2d
commit b27c673c15
3 changed files with 168 additions and 42 deletions

View File

@ -26,8 +26,8 @@
<form id="form" action="{{ .SubmitUrl }}" method="post" class="flex-fair-ns"> <form id="form" action="{{ .SubmitUrl }}" method="post" class="flex-fair-ns">
{{ csrftoken .Session }} {{ csrftoken .Session }}
{{ if not .PostReplyingTo }} {{ if not (or .PostReplyingTo .IsEditing) }}
<input class="b w-100 mb1" name="title" type="text" placeholder="Post title..." value="{{ .PostTitle }}"/> <input class="b w-100 mb1" name="title" type="text" placeholder="Post title..."/>
{{ end }} {{ end }}
{{/* TODO: Reintroduce the toolbar in a way that makes sense for Markdown */}} {{/* TODO: Reintroduce the toolbar in a way that makes sense for Markdown */}}
{{/* {{/*
@ -51,7 +51,7 @@
<input type="button" id="youtube" value="youtube" /> <input type="button" id="youtube" value="youtube" />
</div> </div>
*/}} */}}
<textarea id="editor" class="w-100 h5 minh-5" name="body">{{ .PostBody }}</textarea> <textarea id="editor" class="w-100 h5 minh-5" name="body">{{ if .IsEditing }}{{ .EditInitialContents }}{{ end }}</textarea>
<div class="flex flex-row-reverse justify-start mt2"> <div class="flex flex-row-reverse justify-start mt2">
<input type="submit" class="button ml2" name="submit" value="{{ .SubmitLabel }}" /> <input type="submit" class="button ml2" name="submit" value="{{ .SubmitLabel }}" />
@ -124,15 +124,6 @@
{{/* {{/*
{% if context_reply_to %}
<h4>The post you're replying to:</h4>
<div class="recent-posts">
{% with post=post_reply_to %}
{% include "forum_thread_single_post.html" %}
{% endwith %}
</div>
{% endif %}
{% if context_newer %} {% if context_newer %}
<h4>Replies since then:</h4> <h4>Replies since then:</h4>
<div class="recent-posts"> <div class="recent-posts">
@ -187,7 +178,7 @@
// Load any stored content from localStorage // Load any stored content from localStorage
const storageKey = `${storagePrefix}/${window.location.host}${window.location.pathname}`; const storageKey = `${storagePrefix}/${window.location.host}${window.location.pathname}`;
const storedContents = window.localStorage.getItem(storageKey); const storedContents = window.localStorage.getItem(storageKey);
if (storedContents) { if (storedContents && !tf.value) {
try { try {
const { contents } = JSON.parse(storedContents); const { contents } = JSON.parse(storedContents);
tf.value = contents; tf.value = contents;

View File

@ -41,12 +41,12 @@ type forumSubcategoryData struct {
type editorData struct { type editorData struct {
templates.BaseData templates.BaseData
SubmitUrl string SubmitUrl string
PostTitle string ThreadTitle string
PostBody string
SubmitLabel string SubmitLabel string
IsEditing bool // false if new post, true if updating existing one
ThreadTitle string IsEditing bool // false if new post, true if updating existing one
EditInitialContents string
PostReplyingTo *templates.Post PostReplyingTo *templates.Post
} }
@ -607,7 +607,7 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
panic(oops.New(err, "failed to create thread")) panic(oops.New(err, "failed to create thread"))
} }
postId, _ := createForumPostAndVersion(c.Context(), tx, currentCatId, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, nil) postId, _ := createNewForumPostAndVersion(c.Context(), tx, currentCatId, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, nil)
// Update thread with post id // Update thread with post id
_, err = tx.Exec(c.Context(), _, err = tx.Exec(c.Context(),
@ -658,7 +658,7 @@ func ForumPostReply(c *RequestContext) ResponseData {
return FourOhFour(c) return FourOhFour(c)
} }
c.Perf.StartBlock("SQL", "Fetch post ids for thread") c.Perf.StartBlock("SQL", "Fetch post to reply to")
// TODO: Scope this down to just what you need // TODO: Scope this down to just what you need
type postQuery struct { type postQuery struct {
Thread models.Thread `db:"thread"` Thread models.Thread `db:"thread"`
@ -747,40 +747,154 @@ func ForumPostReplySubmit(c *RequestContext) ResponseData {
unparsed := c.Req.Form.Get("body") unparsed := c.Req.Form.Get("body")
newPostId, _ := createForumPostAndVersion(c.Context(), tx, currentCatId, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, &postId) newPostId, _ := createNewForumPostAndVersion(c.Context(), tx, currentCatId, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, &postId)
err = tx.Commit(c.Context()) err = tx.Commit(c.Context())
if err != nil { if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new forum thread")) return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to forum post"))
} }
newPostUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), threadId, newPostId) newPostUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), threadId, newPostId)
return c.Redirect(newPostUrl, http.StatusSeeOther) return c.Redirect(newPostUrl, http.StatusSeeOther)
} }
func createForumPostAndVersion(ctx context.Context, tx pgx.Tx, catId, threadId, userId, projectId int, unparsedContent string, ipString string, replyId *int) (postId, versionId int) { func ForumPostEdit(c *RequestContext) ResponseData {
parsed := parsing.ParsePostInput(unparsedContent, parsing.RealMarkdown) // TODO(compression): This logic for fetching posts by thread id / post id is gonna
now := time.Now() // show up in a lot of places. It's used multiple times for forums, and also for blogs.
ip := net.ParseIP(ipString) // Consider compressing this later.
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
const previewMaxLength = 100 currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
parsedPlaintext := parsing.ParsePostInput(unparsedContent, parsing.PlaintextMarkdown) if !valid {
preview := parsedPlaintext return FourOhFour(c)
if len(preview) > previewMaxLength-1 {
preview = preview[:previewMaxLength-1] + "…"
} }
requestedThreadId, err := strconv.Atoi(c.PathParams["threadid"])
if err != nil {
return FourOhFour(c)
}
requestedPostId, err := strconv.Atoi(c.PathParams["postid"])
if err != nil {
return FourOhFour(c)
}
c.Perf.StartBlock("SQL", "Fetch post to edit")
// TODO: Scope this down to just what you need
type postQuery struct {
Thread models.Thread `db:"thread"`
Post models.Post `db:"post"`
CurrentVersion models.PostVersion `db:"ver"`
Author *models.User `db:"author"`
Editor *models.User `db:"editor"`
}
postQueryResult, err := db.QueryOne(c.Context(), c.Conn, postQuery{},
`
SELECT $columns
FROM
handmade_thread AS thread
JOIN handmade_post AS post ON post.thread_id = thread.id
JOIN handmade_postversion AS ver ON post.current_id = ver.id
LEFT JOIN auth_user AS author ON post.author_id = author.id
LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id
WHERE
post.category_id = $1
AND post.thread_id = $2
AND post.id = $3
AND NOT post.deleted
ORDER BY postdate
`,
currentCatId,
requestedThreadId,
requestedPostId,
)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
return FourOhFour(c)
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch reply post"))
}
}
result := postQueryResult.(*postQuery)
baseData := getBaseData(c)
baseData.Title = fmt.Sprintf("Editing \"%s\" | %s", result.Thread.Title, *categoryTree[currentCatId].Name)
baseData.MathjaxEnabled = true
// TODO(ben): Set breadcrumbs
templatePost := templates.PostToTemplate(&result.Post, result.Author, c.Theme)
templatePost.AddContentVersion(result.CurrentVersion, result.Editor)
var res ResponseData
res.MustWriteTemplate("editor.html", editorData{
BaseData: baseData,
SubmitUrl: hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), requestedThreadId, requestedPostId),
ThreadTitle: result.Thread.Title,
SubmitLabel: "Submit Edited Post",
IsEditing: true,
EditInitialContents: result.CurrentVersion.TextRaw,
}, c.Perf)
return res
}
func ForumPostEditSubmit(c *RequestContext) ResponseData {
tx, err := c.Conn.Begin(c.Context())
if err != nil {
panic(err)
}
defer tx.Rollback(c.Context())
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
if !valid {
return FourOhFour(c)
}
threadId, err := strconv.Atoi(c.PathParams["threadid"])
if err != nil {
return FourOhFour(c)
}
postId, err := strconv.Atoi(c.PathParams["postid"])
if err != nil {
return FourOhFour(c)
}
c.Req.ParseForm()
unparsed := c.Req.Form.Get("body")
editReason := c.Req.Form.Get("editreason")
createForumPostVersion(c.Context(), tx, postId, unparsed, c.Req.Host, editReason, &c.CurrentUser.ID)
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit forum post"))
}
postUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), threadId, postId)
return c.Redirect(postUrl, http.StatusSeeOther)
}
func createNewForumPostAndVersion(ctx context.Context, tx pgx.Tx, catId, threadId, userId, projectId int, unparsedContent string, ipString string, replyId *int) (postId, versionId int) {
// Create post // Create post
err := tx.QueryRow(ctx, err := tx.QueryRow(ctx,
` `
INSERT INTO handmade_post (postdate, category_id, thread_id, preview, current_id, author_id, category_kind, project_id, reply_id) INSERT INTO handmade_post (postdate, category_id, thread_id, current_id, author_id, category_kind, project_id, reply_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id RETURNING id
`, `,
now, time.Now(),
catId, catId,
threadId, threadId,
preview,
-1, -1,
userId, userId,
models.CatKindForum, models.CatKindForum,
@ -791,35 +905,54 @@ func createForumPostAndVersion(ctx context.Context, tx pgx.Tx, catId, threadId,
panic(oops.New(err, "failed to create post")) panic(oops.New(err, "failed to create post"))
} }
versionId = createForumPostVersion(ctx, tx, postId, unparsedContent, ipString, "", nil)
return
}
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)
ip := net.ParseIP(ipString)
const previewMaxLength = 100
parsedPlaintext := parsing.ParsePostInput(unparsedContent, parsing.PlaintextMarkdown)
preview := parsedPlaintext
if len(preview) > previewMaxLength-1 {
preview = preview[:previewMaxLength-1] + "…"
}
// Create post version // Create post version
err = tx.QueryRow(ctx, err := tx.QueryRow(ctx,
` `
INSERT INTO handmade_postversion (post_id, text_raw, text_parsed, ip, date) INSERT INTO handmade_postversion (post_id, text_raw, text_parsed, ip, date, edit_reason, editor_id)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id RETURNING id
`, `,
postId, postId,
unparsedContent, unparsedContent,
parsed, parsed,
ip, ip,
now, time.Now(),
editReason,
editorId,
).Scan(&versionId) ).Scan(&versionId)
if err != nil { if err != nil {
panic(oops.New(err, "failed to create post version")) panic(oops.New(err, "failed to create post version"))
} }
// Update post with version id // Update post with version id and preview
_, err = tx.Exec(ctx, _, err = tx.Exec(ctx,
` `
UPDATE handmade_post UPDATE handmade_post
SET current_id = $1 SET current_id = $1, preview = $2
WHERE id = $2 WHERE id = $3
`, `,
versionId, versionId,
preview,
postId, postId,
) )
if err != nil { if err != nil {
panic(oops.New(err, "failed to set current post version")) panic(oops.New(err, "failed to set current post version and preview"))
} }
return return

View File

@ -160,6 +160,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
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(ForumPostReplySubmit))
mainRoutes.GET(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEdit))
mainRoutes.POST(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEditSubmit))
mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS) mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)