Add blog post editing
This commit is contained in:
parent
93318c378a
commit
9945ab061d
|
@ -414,17 +414,17 @@ func BuildBlog(projectSlug string, page int) string {
|
|||
return ProjectUrl(path, nil, projectSlug)
|
||||
}
|
||||
|
||||
var RegexBlogThread = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)(-([^/]+))?(/(?P<page>\d+))?$`)
|
||||
var RegexBlogThread = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)(-([^/]+))?$`)
|
||||
|
||||
func BuildBlogThread(projectSlug string, threadId int, title string, page int) string {
|
||||
func BuildBlogThread(projectSlug string, threadId int, title string) string {
|
||||
defer CatchPanic()
|
||||
builder := buildBlogThreadPath(threadId, title, page)
|
||||
builder := buildBlogThreadPath(threadId, title)
|
||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
||||
}
|
||||
|
||||
func BuildBlogThreadWithPostHash(projectSlug string, threadId int, title string, page int, postId int) string {
|
||||
func BuildBlogThreadWithPostHash(projectSlug string, threadId int, title string, postId int) string {
|
||||
defer CatchPanic()
|
||||
builder := buildBlogThreadPath(threadId, title, page)
|
||||
builder := buildBlogThreadPath(threadId, title)
|
||||
return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId))
|
||||
}
|
||||
|
||||
|
@ -463,15 +463,6 @@ func BuildBlogPostReply(projectSlug string, threadId int, postId int) string {
|
|||
return ProjectUrl(builder.String(), nil, projectSlug)
|
||||
}
|
||||
|
||||
var RegexBlogPostQuote = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)/quote$`)
|
||||
|
||||
func BuildBlogPostQuote(projectSlug string, threadId int, postId int) string {
|
||||
defer CatchPanic()
|
||||
builder := buildBlogPostPath(threadId, postId)
|
||||
builder.WriteString("/quote")
|
||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
||||
}
|
||||
|
||||
/*
|
||||
* Library
|
||||
*/
|
||||
|
@ -525,6 +516,13 @@ func BuildProjectCSS(color string) string {
|
|||
return Url("/assets/project.css", []Q{{"color", color}})
|
||||
}
|
||||
|
||||
var RegexEditorPreviewsJS = regexp.MustCompile("^/assets/editorpreviews.js$")
|
||||
|
||||
func BuildEditorPreviewsJS() string {
|
||||
defer CatchPanic()
|
||||
return Url("/assets/editorpreviews.js", nil)
|
||||
}
|
||||
|
||||
// NOTE(asaf): No Regex or tests for remote assets, since we don't parse it ourselves
|
||||
func BuildS3Asset(s3key string) string {
|
||||
defer CatchPanic()
|
||||
|
@ -665,11 +663,7 @@ func buildForumPostPath(subforums []string, threadId int, postId int) *strings.B
|
|||
return builder
|
||||
}
|
||||
|
||||
func buildBlogThreadPath(threadId int, title string, page int) *strings.Builder {
|
||||
if page < 1 {
|
||||
panic(oops.New(nil, "Invalid blog thread page (%d), must be >= 1", page))
|
||||
}
|
||||
|
||||
func buildBlogThreadPath(threadId int, title string) *strings.Builder {
|
||||
if threadId < 1 {
|
||||
panic(oops.New(nil, "Invalid blog thread ID (%d), must be >= 1", threadId))
|
||||
}
|
||||
|
@ -682,10 +676,6 @@ func buildBlogThreadPath(threadId int, title string, page int) *strings.Builder
|
|||
builder.WriteRune('-')
|
||||
builder.WriteString(PathSafeTitle(title))
|
||||
}
|
||||
if page > 1 {
|
||||
builder.WriteRune('/')
|
||||
builder.WriteString(strconv.Itoa(page))
|
||||
}
|
||||
|
||||
return &builder
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
}
|
||||
|
||||
.monospace {
|
||||
font-family: "Fira Mono", monospace;
|
||||
font-family: $monospace-fonts;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
padding: 0.2em 0 0.05em;
|
||||
border-radius: 3px;
|
||||
|
@ -52,7 +52,7 @@
|
|||
}
|
||||
|
||||
pre {
|
||||
font-family: "Fira Mono", monospace;
|
||||
font-family: $monospace-fonts;
|
||||
}
|
||||
|
||||
.hmn-code {
|
||||
|
|
|
@ -136,8 +136,14 @@ hr {
|
|||
max-width: 300px;
|
||||
}
|
||||
|
||||
$monospace-fonts: "Fira Mono", monospace;
|
||||
|
||||
.mono {
|
||||
font-family: $monospace-fonts;
|
||||
}
|
||||
|
||||
article code {
|
||||
font-family: "Fira Mono", monospace;
|
||||
font-family: $monospace-fonts;
|
||||
}
|
||||
|
||||
.big { font-size:120%; }
|
||||
|
@ -318,6 +324,10 @@ article code {
|
|||
min-height: $height-5;
|
||||
}
|
||||
|
||||
.minh-6 {
|
||||
min-height: $height-6;
|
||||
}
|
||||
|
||||
.fira {
|
||||
font-family: "Fira Sans", sans-serif;
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
// TODO: Old and wrong and bad
|
||||
.toolbar {
|
||||
@include usevar('background-color', 'editor-toolbar-background');
|
||||
@include usevar('border-color', 'editor-toolbar-border-color');
|
||||
|
@ -35,44 +36,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
.editor {
|
||||
.toolbar {
|
||||
width:95%;
|
||||
margin:10px auto;
|
||||
|
||||
select {
|
||||
font-size:10pt;
|
||||
border:0px;
|
||||
|
||||
&:hover {
|
||||
border:0px;
|
||||
}
|
||||
|
||||
&:focus{
|
||||
border:0px;
|
||||
}
|
||||
}
|
||||
|
||||
#bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#monospace {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
#url {
|
||||
text-decoration: underline;
|
||||
font-style: italic;
|
||||
}
|
||||
@media #{$breakpoint-not-small} {
|
||||
#preview-container {
|
||||
max-height: calc(100vh - 20rem);
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -131,7 +131,7 @@
|
|||
}
|
||||
|
||||
.codeblocktable {
|
||||
font-family: "Fira Mono", monospace;
|
||||
font-family: $monospace-fonts;
|
||||
font-size:14px;
|
||||
overflow:auto;
|
||||
line-height:1.5em;
|
||||
|
@ -406,7 +406,7 @@ li.post-entry {
|
|||
|
||||
border-width: 1px;
|
||||
border-radius: 2px;
|
||||
font-family: "Fira Mono", monospace;
|
||||
font-family: $monospace-fonts;
|
||||
width:49%;
|
||||
box-sizing:border-box;
|
||||
padding: 4px;
|
||||
|
|
|
@ -36,6 +36,7 @@ $height-2: 2rem !default;
|
|||
$height-3: 4rem !default;
|
||||
$height-4: 8rem !default;
|
||||
$height-5: 16rem !default;
|
||||
$height-6: 32rem !default;
|
||||
$width-1: 1rem !default;
|
||||
$width-2: 2rem !default;
|
||||
$width-3: 4rem !default;
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
{{ template "base.html" . }}
|
||||
|
||||
{{ define "extrahead" }}
|
||||
{{/* TODO: These are no longer useful? */}}
|
||||
<link rel="stylesheet" href="{{ static "editor.css" }}" />
|
||||
<script src="{{ static "util.js" }}"></script>
|
||||
<script src="{{ static "editor.js" }}"></script>
|
||||
|
||||
<script src="{{ static "go_wasm_exec.js" }}"></script>
|
||||
<script>
|
||||
const previewWorker = new Worker('{{ static "js/editorpreviews.js" }}');
|
||||
const previewWorker = new Worker('/assets/editorpreviews.js');
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
@ -19,15 +20,15 @@
|
|||
|
||||
{{ define "content" }}
|
||||
<div class="content-block ph3 ph0-ns">
|
||||
{{ if .Title }}
|
||||
{{ if not .CanEditTitle }}
|
||||
<h2>{{ .Title }}</h2>
|
||||
{{ end }}
|
||||
<div class="flex flex-column flex-row-ns">
|
||||
<form id="form" action="{{ .SubmitUrl }}" method="post" class="flex-fair-ns">
|
||||
{{ csrftoken .Session }}
|
||||
|
||||
{{ if not (or .PostReplyingTo .IsEditing) }}
|
||||
<input id="title" class="b w-100 mb1" name="title" type="text" placeholder="Post title..."/>
|
||||
{{ if .CanEditTitle }}
|
||||
<input id="title" class="b w-100 mb1" name="title" type="text" placeholder="Post title..." value="{{ .Title }}" />
|
||||
{{ end }}
|
||||
{{/* TODO: Reintroduce the toolbar in a way that makes sense for Markdown */}}
|
||||
{{/*
|
||||
|
@ -51,7 +52,7 @@
|
|||
<input type="button" id="youtube" value="youtube" />
|
||||
</div>
|
||||
*/}}
|
||||
<textarea id="editor" class="w-100 h5 minh-5" name="body">{{ if .IsEditing }}{{ .EditInitialContents }}{{ end }}</textarea>
|
||||
<textarea id="editor" class="w-100 h6 minh-6 pa2 mono lh-copy" name="body">{{ .EditInitialContents }}</textarea>
|
||||
|
||||
<div class="flex flex-row-reverse justify-start mt2">
|
||||
<input type="submit" class="button ml2" name="submit" value="{{ .SubmitLabel }}" />
|
||||
|
@ -99,7 +100,7 @@
|
|||
{% endif %}
|
||||
*/}}
|
||||
</form>
|
||||
<div class="post post-preview mv3 mathjax flex-fair-ns mv0-ns ml3-ns">
|
||||
<div id="preview-container" class="post post-preview mv3 mathjax flex-fair-ns mv0-ns ml3-ns overflow-auto">
|
||||
<div id="preview" class="body contents"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
importScripts('/public/go_wasm_exec.js');
|
||||
|
||||
/*
|
||||
NOTE(ben): The structure here is a little funny but allows for some debouncing. Any postMessages
|
||||
that got queued up can run all at once, then it can process the latest one.
|
||||
*/
|
||||
|
||||
let ready = false;
|
||||
let inputData = null;
|
||||
|
||||
onmessage = ({ data }) => {
|
||||
inputData = data;
|
||||
setTimeout(doPreview, 0);
|
||||
}
|
||||
|
||||
const go = new Go();
|
||||
WebAssembly.instantiateStreaming(fetch('/public/parsing.wasm'), go.importObject)
|
||||
.then(result => {
|
||||
go.run(result.instance); // don't await this; we want it to be continuously running
|
||||
ready = true;
|
||||
setTimeout(doPreview, 0);
|
||||
});
|
||||
|
||||
const doPreview = () => {
|
||||
if (!ready || inputData === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = parseMarkdown(inputData);
|
||||
inputData = null;
|
||||
|
||||
postMessage(result);
|
||||
}
|
|
@ -30,7 +30,7 @@ func Init() {
|
|||
|
||||
files, _ := templateFs.ReadDir("src")
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f.Name(), ".html") {
|
||||
if hasSuffix(f.Name(), ".html") {
|
||||
t := template.New(f.Name())
|
||||
t = t.Funcs(sprig.FuncMap())
|
||||
t = t.Funcs(HMNTemplateFuncs)
|
||||
|
@ -40,17 +40,7 @@ func Init() {
|
|||
}
|
||||
|
||||
Templates[f.Name()] = t
|
||||
} else if strings.HasSuffix(f.Name(), ".css") {
|
||||
t := template.New(f.Name())
|
||||
t = t.Funcs(sprig.FuncMap())
|
||||
t = t.Funcs(HMNTemplateFuncs)
|
||||
t, err := t.ParseFS(templateFs, "src/"+f.Name())
|
||||
if err != nil {
|
||||
logging.Fatal().Str("filename", f.Name()).Err(err).Msg("failed to parse template")
|
||||
}
|
||||
|
||||
Templates[f.Name()] = t
|
||||
} else if strings.HasSuffix(f.Name(), ".xml") {
|
||||
} else if hasSuffix(f.Name(), ".css", ".js", ".xml") {
|
||||
t := template.New(f.Name())
|
||||
t = t.Funcs(sprig.FuncMap())
|
||||
t = t.Funcs(HMNTemplateFuncs)
|
||||
|
@ -64,6 +54,15 @@ func Init() {
|
|||
}
|
||||
}
|
||||
|
||||
func hasSuffix(s string, suffixes ...string) bool {
|
||||
for _, suffix := range suffixes {
|
||||
if strings.HasSuffix(s, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func names(ts []*template.Template) []string {
|
||||
result := make([]string, len(ts))
|
||||
for i, t := range ts {
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
package website
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"git.handmade.network/hmn/hmn/src/templates"
|
||||
)
|
||||
|
||||
|
@ -35,11 +38,11 @@ func BlogThread(c *RequestContext) ResponseData {
|
|||
for _, p := range posts {
|
||||
post := templates.PostToTemplate(&p.Post, p.Author, c.Theme)
|
||||
post.AddContentVersion(p.CurrentVersion, p.Editor)
|
||||
addBlogUrlsToPost(&post, c.CurrentProject.Slug, p.Thread.ID, p.Post.ID)
|
||||
addBlogUrlsToPost(&post, c.CurrentProject.Slug, &p.Thread, p.Post.ID)
|
||||
|
||||
if p.ReplyPost != nil {
|
||||
reply := templates.PostToTemplate(p.ReplyPost, p.ReplyAuthor, c.Theme)
|
||||
addBlogUrlsToPost(&reply, c.CurrentProject.Slug, p.Thread.ID, p.Post.ID)
|
||||
addBlogUrlsToPost(&reply, c.CurrentProject.Slug, &p.Thread, p.Post.ID)
|
||||
post.ReplyPost = &reply
|
||||
}
|
||||
|
||||
|
@ -67,10 +70,83 @@ func BlogPostRedirectToThread(c *RequestContext) ResponseData {
|
|||
|
||||
thread := FetchThread(c.Context(), c.Conn, cd.ThreadID)
|
||||
|
||||
threadUrl := hmnurl.BuildBlogThread(c.CurrentProject.Slug, cd.ThreadID, thread.Title, 1)
|
||||
threadUrl := hmnurl.BuildBlogThread(c.CurrentProject.Slug, cd.ThreadID, thread.Title)
|
||||
return c.Redirect(threadUrl, http.StatusFound)
|
||||
}
|
||||
|
||||
func BlogPostEdit(c *RequestContext) ResponseData {
|
||||
cd, ok := getCommonBlogData(c)
|
||||
if !ok {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
if !UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
postData := FetchPostAndStuff(c.Context(), c.Conn, cd.ThreadID, cd.PostID)
|
||||
|
||||
baseData := getBaseData(c)
|
||||
if postData.Thread.FirstID == postData.Post.ID {
|
||||
baseData.Title = fmt.Sprintf("Editing \"%s\" | %s", postData.Thread.Title, c.CurrentProject.Name)
|
||||
} else {
|
||||
baseData.Title = fmt.Sprintf("Editing Post | %s", c.CurrentProject.Name)
|
||||
}
|
||||
baseData.MathjaxEnabled = true
|
||||
// TODO(ben): Set breadcrumbs
|
||||
|
||||
editData := getEditorDataForEdit(baseData, postData)
|
||||
editData.SubmitUrl = hmnurl.BuildBlogPostEdit(c.CurrentProject.Slug, cd.ThreadID, cd.PostID)
|
||||
editData.SubmitLabel = "Submit Edited Post"
|
||||
if postData.Thread.FirstID != postData.Post.ID {
|
||||
editData.SubmitLabel = "Submit Edited Comment"
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
res.MustWriteTemplate("editor.html", editData, c.Perf)
|
||||
return res
|
||||
}
|
||||
|
||||
func BlogPostEditSubmit(c *RequestContext) ResponseData {
|
||||
cd, ok := getCommonBlogData(c)
|
||||
if !ok {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
if !UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
|
||||
postData := FetchPostAndStuff(c.Context(), tx, cd.ThreadID, cd.PostID)
|
||||
|
||||
c.Req.ParseForm()
|
||||
title := c.Req.Form.Get("title")
|
||||
unparsed := c.Req.Form.Get("body")
|
||||
editReason := c.Req.Form.Get("editreason")
|
||||
if title != "" && postData.Thread.FirstID != postData.Post.ID {
|
||||
return RejectRequest(c, "You can only edit the title by editing the first post.")
|
||||
}
|
||||
if unparsed == "" {
|
||||
return RejectRequest(c, "You must provide a post body.")
|
||||
}
|
||||
|
||||
CreatePostVersion(c.Context(), tx, postData.Post.ID, 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 blog post"))
|
||||
}
|
||||
|
||||
postUrl := hmnurl.BuildBlogThreadWithPostHash(c.CurrentProject.Slug, cd.ThreadID, postData.Thread.Title, cd.PostID)
|
||||
return c.Redirect(postUrl, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type commonBlogData struct {
|
||||
c *RequestContext
|
||||
|
||||
|
@ -145,9 +221,9 @@ func getCommonBlogData(c *RequestContext) (commonBlogData, bool) {
|
|||
return res, true
|
||||
}
|
||||
|
||||
func addBlogUrlsToPost(p *templates.Post, projectSlug string, threadId int, postId int) {
|
||||
p.Url = hmnurl.BuildBlogPost(projectSlug, threadId, postId)
|
||||
p.DeleteUrl = hmnurl.BuildBlogPostDelete(projectSlug, threadId, postId)
|
||||
p.EditUrl = hmnurl.BuildBlogPostEdit(projectSlug, threadId, postId)
|
||||
p.ReplyUrl = hmnurl.BuildBlogPostReply(projectSlug, threadId, postId)
|
||||
func addBlogUrlsToPost(p *templates.Post, projectSlug string, thread *models.Thread, postId int) {
|
||||
p.Url = hmnurl.BuildBlogThreadWithPostHash(projectSlug, thread.ID, thread.Title, postId)
|
||||
p.DeleteUrl = hmnurl.BuildBlogPostDelete(projectSlug, thread.ID, postId)
|
||||
p.EditUrl = hmnurl.BuildBlogPostEdit(projectSlug, thread.ID, postId)
|
||||
p.ReplyUrl = hmnurl.BuildBlogPostReply(projectSlug, thread.ID, postId)
|
||||
}
|
||||
|
|
|
@ -1,11 +1,8 @@
|
|||
package website
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
@ -15,10 +12,8 @@ import (
|
|||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"git.handmade.network/hmn/hmn/src/parsing"
|
||||
"git.handmade.network/hmn/hmn/src/templates"
|
||||
"git.handmade.network/hmn/hmn/src/utils"
|
||||
"github.com/jackc/pgx/v4"
|
||||
)
|
||||
|
||||
type forumData struct {
|
||||
|
@ -38,16 +33,44 @@ type forumSubforumData struct {
|
|||
TotalThreads int
|
||||
}
|
||||
|
||||
type editActionType string
|
||||
|
||||
type editorData struct {
|
||||
templates.BaseData
|
||||
SubmitUrl string
|
||||
Title string
|
||||
SubmitLabel string
|
||||
|
||||
IsEditing bool // false if new post, true if updating existing one
|
||||
// The following are filled out automatically by the
|
||||
// getEditorDataFor* functions.
|
||||
Title string
|
||||
CanEditTitle bool
|
||||
IsEditing bool
|
||||
EditInitialContents string
|
||||
PostReplyingTo *templates.Post
|
||||
}
|
||||
|
||||
PostReplyingTo *templates.Post
|
||||
func getEditorDataForNew(baseData templates.BaseData, replyPost *templates.Post) editorData {
|
||||
result := editorData{
|
||||
BaseData: baseData,
|
||||
CanEditTitle: replyPost == nil,
|
||||
PostReplyingTo: replyPost,
|
||||
}
|
||||
|
||||
if replyPost != nil {
|
||||
result.Title = "Replying to post"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func getEditorDataForEdit(baseData templates.BaseData, p postAndRelatedModels) editorData {
|
||||
return editorData{
|
||||
BaseData: baseData,
|
||||
Title: p.Thread.Title,
|
||||
CanEditTitle: p.Thread.FirstID == p.Post.ID,
|
||||
IsEditing: true,
|
||||
EditInitialContents: p.CurrentVersion.TextRaw,
|
||||
}
|
||||
}
|
||||
|
||||
func Forum(c *RequestContext) ResponseData {
|
||||
|
@ -590,12 +613,12 @@ func ForumNewThread(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
editData := getEditorDataForNew(baseData, nil)
|
||||
editData.SubmitUrl = hmnurl.BuildForumNewThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), true)
|
||||
editData.SubmitLabel = "Post New Thread"
|
||||
|
||||
var res ResponseData
|
||||
res.MustWriteTemplate("editor.html", editorData{
|
||||
BaseData: baseData,
|
||||
SubmitUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), true),
|
||||
SubmitLabel: "Post New Thread",
|
||||
}, c.Perf)
|
||||
res.MustWriteTemplate("editor.html", editData, c.Perf)
|
||||
return res
|
||||
}
|
||||
|
||||
|
@ -642,23 +665,8 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
|
|||
panic(oops.New(err, "failed to create thread"))
|
||||
}
|
||||
|
||||
postId, _ := createNewForumPostAndVersion(c.Context(), tx, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, nil)
|
||||
|
||||
// Update thread with post id
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
UPDATE handmade_thread
|
||||
SET
|
||||
first_id = $1,
|
||||
last_id = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
postId,
|
||||
threadId,
|
||||
)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to set thread post ids"))
|
||||
}
|
||||
// Create everything else
|
||||
CreateNewPost(c.Context(), tx, c.CurrentProject.ID, threadId, models.ThreadTypeForumPost, c.CurrentUser.ID, nil, unparsed, c.Req.Host)
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
if err != nil {
|
||||
|
@ -685,15 +693,12 @@ func ForumPostReply(c *RequestContext) ResponseData {
|
|||
templatePost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme)
|
||||
templatePost.AddContentVersion(postData.CurrentVersion, postData.Editor)
|
||||
|
||||
var res ResponseData
|
||||
res.MustWriteTemplate("editor.html", editorData{
|
||||
BaseData: baseData,
|
||||
SubmitUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID),
|
||||
SubmitLabel: "Submit Reply",
|
||||
editData := getEditorDataForNew(baseData, &templatePost)
|
||||
editData.SubmitUrl = hmnurl.BuildForumPostReply(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
|
||||
editData.SubmitLabel = "Submit Reply"
|
||||
|
||||
Title: "Replying to post",
|
||||
PostReplyingTo: &templatePost,
|
||||
}, c.Perf)
|
||||
var res ResponseData
|
||||
res.MustWriteTemplate("editor.html", editData, c.Perf)
|
||||
return res
|
||||
}
|
||||
|
||||
|
@ -711,10 +716,9 @@ func ForumPostReplySubmit(c *RequestContext) ResponseData {
|
|||
|
||||
c.Req.ParseForm()
|
||||
// TODO(ben): Validation
|
||||
|
||||
unparsed := c.Req.Form.Get("body")
|
||||
|
||||
newPostId, _ := createNewForumPostAndVersion(c.Context(), tx, cd.ThreadID, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, &cd.PostID)
|
||||
newPostId, _ := CreateNewPost(c.Context(), tx, c.CurrentProject.ID, cd.ThreadID, models.ThreadTypeForumPost, c.CurrentUser.ID, &cd.PostID, unparsed, c.Req.Host)
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
if err != nil {
|
||||
|
@ -731,30 +735,27 @@ func ForumPostEdit(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
if !cd.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser) {
|
||||
if !UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
postData := FetchPostAndStuff(c.Context(), c.Conn, cd.ThreadID, cd.PostID)
|
||||
|
||||
baseData := getBaseData(c)
|
||||
baseData.Title = fmt.Sprintf("Editing \"%s\" | %s", postData.Thread.Title, cd.SubforumTree[cd.SubforumID].Name)
|
||||
if postData.Thread.FirstID == postData.Post.ID {
|
||||
baseData.Title = fmt.Sprintf("Editing \"%s\" | %s", postData.Thread.Title, cd.SubforumTree[cd.SubforumID].Name)
|
||||
} else {
|
||||
baseData.Title = fmt.Sprintf("Editing Post | %s", cd.SubforumTree[cd.SubforumID].Name)
|
||||
}
|
||||
baseData.MathjaxEnabled = true
|
||||
// TODO(ben): Set breadcrumbs
|
||||
|
||||
templatePost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme)
|
||||
templatePost.AddContentVersion(postData.CurrentVersion, postData.Editor)
|
||||
editData := getEditorDataForEdit(baseData, postData)
|
||||
editData.SubmitUrl = hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
|
||||
editData.SubmitLabel = "Submit Edited Post"
|
||||
|
||||
var res ResponseData
|
||||
res.MustWriteTemplate("editor.html", editorData{
|
||||
BaseData: baseData,
|
||||
SubmitUrl: hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID),
|
||||
Title: postData.Thread.Title,
|
||||
SubmitLabel: "Submit Edited Post",
|
||||
|
||||
IsEditing: true,
|
||||
EditInitialContents: postData.CurrentVersion.TextRaw,
|
||||
}, c.Perf)
|
||||
res.MustWriteTemplate("editor.html", editData, c.Perf)
|
||||
return res
|
||||
}
|
||||
|
||||
|
@ -764,7 +765,7 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
if !cd.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser) {
|
||||
if !UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
|
@ -779,7 +780,7 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData {
|
|||
unparsed := c.Req.Form.Get("body")
|
||||
editReason := c.Req.Form.Get("editreason")
|
||||
|
||||
createForumPostVersion(c.Context(), tx, cd.PostID, unparsed, c.Req.Host, editReason, &c.CurrentUser.ID)
|
||||
CreatePostVersion(c.Context(), tx, cd.PostID, unparsed, c.Req.Host, editReason, &c.CurrentUser.ID)
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
if err != nil {
|
||||
|
@ -796,7 +797,7 @@ func ForumPostDelete(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
if !cd.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser) {
|
||||
if !UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
|
@ -831,7 +832,7 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
if !cd.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser) {
|
||||
if !UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
|
@ -841,208 +842,20 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
|
||||
isFirstPost, err := db.QueryBool(c.Context(), tx,
|
||||
`
|
||||
SELECT thread.first_id = $1
|
||||
FROM
|
||||
handmade_thread AS thread
|
||||
WHERE
|
||||
thread.id = $2
|
||||
`,
|
||||
cd.PostID,
|
||||
cd.ThreadID,
|
||||
)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check if post was the first post in the thread"))
|
||||
}
|
||||
|
||||
if isFirstPost {
|
||||
// Just delete the whole thread and all its posts.
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
UPDATE handmade_thread
|
||||
SET deleted = TRUE
|
||||
WHERE id = $1
|
||||
`,
|
||||
cd.ThreadID,
|
||||
)
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
UPDATE handmade_post
|
||||
SET deleted = TRUE
|
||||
WHERE thread_id = $1
|
||||
`,
|
||||
cd.ThreadID,
|
||||
)
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete thread and posts when deleting the first post"))
|
||||
}
|
||||
|
||||
forumUrl := hmnurl.BuildForum(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), 1)
|
||||
return c.Redirect(forumUrl, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
UPDATE handmade_post
|
||||
SET deleted = TRUE
|
||||
WHERE
|
||||
id = $1
|
||||
`,
|
||||
cd.PostID,
|
||||
)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to mark forum post as deleted"))
|
||||
}
|
||||
|
||||
err = fixThreadPostIds(c.Context(), tx, cd.ThreadID)
|
||||
if err != nil {
|
||||
if errors.Is(err, errThreadEmpty) {
|
||||
panic("it shouldn't be possible to delete the last remaining post in a thread, without it also being the first post in the thread and thus resulting in the whole thread getting deleted earlier")
|
||||
} else {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fix up thread post ids"))
|
||||
}
|
||||
}
|
||||
threadDeleted := DeletePost(c.Context(), tx, cd.ThreadID, cd.PostID)
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete post"))
|
||||
}
|
||||
|
||||
threadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, "", 1) // TODO: Go to the last page of the thread? Or the post before the post we just deleted?
|
||||
return c.Redirect(threadUrl, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func createNewForumPostAndVersion(ctx context.Context, tx pgx.Tx, threadId, userId, projectId int, unparsedContent string, ipString string, replyId *int) (postId, versionId int) {
|
||||
// Create post
|
||||
err := tx.QueryRow(ctx,
|
||||
`
|
||||
INSERT INTO handmade_post (postdate, thread_id, thread_type, current_id, author_id, project_id, reply_id, preview)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id
|
||||
`,
|
||||
time.Now(),
|
||||
threadId,
|
||||
models.ThreadTypeForumPost,
|
||||
-1,
|
||||
userId,
|
||||
projectId,
|
||||
replyId,
|
||||
"", // empty preview, will be updated later
|
||||
).Scan(&postId)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to create post"))
|
||||
if threadDeleted {
|
||||
forumUrl := hmnurl.BuildForum(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), 1)
|
||||
return c.Redirect(forumUrl, http.StatusSeeOther)
|
||||
} else {
|
||||
threadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, "", 1) // TODO: Go to the last page of the thread? Or the post before the post we just deleted?
|
||||
return c.Redirect(threadUrl, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
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.ParseMarkdown(unparsedContent, parsing.RealMarkdown)
|
||||
ip := net.ParseIP(ipString)
|
||||
|
||||
const previewMaxLength = 100
|
||||
parsedPlaintext := parsing.ParseMarkdown(unparsedContent, parsing.PlaintextMarkdown)
|
||||
preview := parsedPlaintext
|
||||
if len(preview) > previewMaxLength-1 {
|
||||
preview = preview[:previewMaxLength-1] + "…"
|
||||
}
|
||||
|
||||
// Create post version
|
||||
err := tx.QueryRow(ctx,
|
||||
`
|
||||
INSERT INTO handmade_postversion (post_id, text_raw, text_parsed, ip, date, edit_reason, editor_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
`,
|
||||
postId,
|
||||
unparsedContent,
|
||||
parsed,
|
||||
ip,
|
||||
time.Now(),
|
||||
editReason,
|
||||
editorId,
|
||||
).Scan(&versionId)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to create post version"))
|
||||
}
|
||||
|
||||
// Update post with version id and preview
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE handmade_post
|
||||
SET current_id = $1, preview = $2
|
||||
WHERE id = $3
|
||||
`,
|
||||
versionId,
|
||||
preview,
|
||||
postId,
|
||||
)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to set current post version and preview"))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var errThreadEmpty = errors.New("thread contained no non-deleted posts")
|
||||
|
||||
/*
|
||||
Ensures that the first_id and last_id on the thread are still good.
|
||||
|
||||
Returns errThreadEmpty if the thread contains no visible posts any more.
|
||||
You should probably mark the thread as deleted in this case.
|
||||
*/
|
||||
func fixThreadPostIds(ctx context.Context, tx pgx.Tx, threadId int) error {
|
||||
postsIter, err := db.Query(ctx, tx, models.Post{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_post
|
||||
WHERE
|
||||
thread_id = $1
|
||||
AND NOT deleted
|
||||
`,
|
||||
threadId,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to fetch posts when fixing up thread")
|
||||
}
|
||||
|
||||
var firstPost, lastPost *models.Post
|
||||
for _, ipost := range postsIter.ToSlice() {
|
||||
post := ipost.(*models.Post)
|
||||
|
||||
if firstPost == nil || post.PostDate.Before(firstPost.PostDate) {
|
||||
firstPost = post
|
||||
}
|
||||
if lastPost == nil || post.PostDate.After(lastPost.PostDate) {
|
||||
lastPost = post
|
||||
}
|
||||
}
|
||||
|
||||
if firstPost == nil || lastPost == nil {
|
||||
return errThreadEmpty
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE handmade_thread
|
||||
SET first_id = $1, last_id = $2
|
||||
WHERE id = $3
|
||||
`,
|
||||
firstPost.ID,
|
||||
lastPost.ID,
|
||||
threadId,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to update thread first/last ids")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type commonForumData struct {
|
||||
|
|
|
@ -182,7 +182,7 @@ func Index(c *RequestContext) ResponseData {
|
|||
|
||||
landingPageProject.FeaturedPost = &LandingPageFeaturedPost{
|
||||
Title: projectPost.Thread.Title,
|
||||
Url: hmnurl.BuildBlogPost(proj.Slug, projectPost.Thread.ID, projectPost.Post.ID),
|
||||
Url: hmnurl.BuildBlogThread(proj.Slug, projectPost.Thread.ID, projectPost.Thread.Title),
|
||||
User: templates.UserToTemplate(&projectPost.User, c.Theme),
|
||||
Date: projectPost.Post.PostDate,
|
||||
Unread: !hasRead,
|
||||
|
@ -323,13 +323,13 @@ func Index(c *RequestContext) ResponseData {
|
|||
FeedUrl: hmnurl.BuildFeed(),
|
||||
PodcastUrl: hmnurl.BuildPodcast(models.HMNProjectSlug),
|
||||
StreamsUrl: hmnurl.BuildStreams(),
|
||||
IRCUrl: hmnurl.BuildBlogThread(models.HMNProjectSlug, 1138, "[Tutorial] Handmade Network IRC", 1),
|
||||
IRCUrl: hmnurl.BuildBlogThread(models.HMNProjectSlug, 1138, "[Tutorial] Handmade Network IRC"),
|
||||
DiscordUrl: "https://discord.gg/hxWxDee",
|
||||
ShowUrl: "https://handmadedev.show/",
|
||||
ShowcaseUrl: hmnurl.BuildShowcase(),
|
||||
NewsPost: LandingPageFeaturedPost{
|
||||
Title: newsPostResult.Thread.Title,
|
||||
Url: hmnurl.BuildBlogPost(models.HMNProjectSlug, newsPostResult.Thread.ID, newsPostResult.Post.ID),
|
||||
Url: hmnurl.BuildBlogThread(models.HMNProjectSlug, newsPostResult.Thread.ID, newsPostResult.Thread.Title),
|
||||
User: templates.UserToTemplate(&newsPostResult.User, c.Theme),
|
||||
Date: newsPostResult.Post.PostDate,
|
||||
Unread: true, // TODO
|
||||
|
|
|
@ -10,7 +10,7 @@ import (
|
|||
func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder *models.SubforumLineageBuilder, projectSlug string) string {
|
||||
switch post.ThreadType {
|
||||
case models.ThreadTypeProjectArticle:
|
||||
return hmnurl.BuildBlogPost(projectSlug, post.ThreadID, post.ID)
|
||||
return hmnurl.BuildBlogThreadWithPostHash(projectSlug, post.ThreadID, thread.Title, post.ID)
|
||||
case models.ThreadTypeForumPost:
|
||||
return hmnurl.BuildForumPost(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), post.ThreadID, post.ID)
|
||||
}
|
||||
|
|
|
@ -168,6 +168,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
|
|||
|
||||
mainRoutes.GET(hmnurl.RegexBlogThread, BlogThread)
|
||||
mainRoutes.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread)
|
||||
mainRoutes.GET(hmnurl.RegexBlogPostEdit, BlogPostEdit)
|
||||
mainRoutes.POST(hmnurl.RegexBlogPostEdit, BlogPostEditSubmit)
|
||||
|
||||
mainRoutes.GET(hmnurl.RegexPodcast, PodcastIndex)
|
||||
mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit)
|
||||
|
@ -180,6 +182,12 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
|
|||
mainRoutes.GET(hmnurl.RegexPodcastRSS, PodcastRSS)
|
||||
|
||||
mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)
|
||||
mainRoutes.GET(hmnurl.RegexEditorPreviewsJS, func(c *RequestContext) ResponseData {
|
||||
var res ResponseData
|
||||
res.MustWriteTemplate("editorpreviews.js", nil, c.Perf)
|
||||
res.Header().Add("Content-Type", "application/javascript")
|
||||
return res
|
||||
})
|
||||
|
||||
// Other
|
||||
mainRoutes.AnyMethod(hmnurl.RegexCatchAll, FourOhFour)
|
||||
|
|
|
@ -4,10 +4,14 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"git.handmade.network/hmn/hmn/src/parsing"
|
||||
"github.com/jackc/pgx/v4"
|
||||
)
|
||||
|
||||
type postAndRelatedModels struct {
|
||||
|
@ -187,7 +191,7 @@ func FetchThreadPostsAndStuff(
|
|||
return thread, posts
|
||||
}
|
||||
|
||||
func (cd *commonForumData) UserCanEditPost(ctx context.Context, connOrTx db.ConnOrTx, user models.User) bool {
|
||||
func UserCanEditPost(ctx context.Context, connOrTx db.ConnOrTx, user models.User, postId int) bool {
|
||||
if user.IsStaff {
|
||||
return true
|
||||
}
|
||||
|
@ -204,7 +208,7 @@ func (cd *commonForumData) UserCanEditPost(ctx context.Context, connOrTx db.Conn
|
|||
post.id = $1
|
||||
AND NOT post.deleted
|
||||
`,
|
||||
cd.PostID,
|
||||
postId,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||
|
@ -217,3 +221,216 @@ func (cd *commonForumData) UserCanEditPost(ctx context.Context, connOrTx db.Conn
|
|||
|
||||
return result.AuthorID != nil && *result.AuthorID == user.ID
|
||||
}
|
||||
|
||||
func CreateNewPost(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
projectId int,
|
||||
threadId int, threadType models.ThreadType,
|
||||
userId int,
|
||||
replyId *int,
|
||||
unparsedContent string,
|
||||
ipString string,
|
||||
) (postId, versionId int) {
|
||||
// Create post
|
||||
err := tx.QueryRow(ctx,
|
||||
`
|
||||
INSERT INTO handmade_post (postdate, thread_id, thread_type, current_id, author_id, project_id, reply_id, preview)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id
|
||||
`,
|
||||
time.Now(),
|
||||
threadId,
|
||||
models.ThreadTypeForumPost,
|
||||
-1,
|
||||
userId,
|
||||
projectId,
|
||||
replyId,
|
||||
"", // empty preview, will be updated later
|
||||
).Scan(&postId)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to create post"))
|
||||
}
|
||||
|
||||
// Create and associate version
|
||||
versionId = CreatePostVersion(ctx, tx, postId, unparsedContent, ipString, "", nil)
|
||||
|
||||
// Fix up thread
|
||||
err = FixThreadPostIds(ctx, tx, threadId)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to fix up thread post IDs"))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func DeletePost(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
threadId, postId int,
|
||||
) (threadDeleted bool) {
|
||||
isFirstPost, err := db.QueryBool(ctx, tx,
|
||||
`
|
||||
SELECT thread.first_id = $1
|
||||
FROM
|
||||
handmade_thread AS thread
|
||||
WHERE
|
||||
thread.id = $2
|
||||
`,
|
||||
postId,
|
||||
threadId,
|
||||
)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to check if post was the first post in the thread"))
|
||||
}
|
||||
|
||||
if isFirstPost {
|
||||
// Just delete the whole thread and all its posts.
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE handmade_thread
|
||||
SET deleted = TRUE
|
||||
WHERE id = $1
|
||||
`,
|
||||
threadId,
|
||||
)
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE handmade_post
|
||||
SET deleted = TRUE
|
||||
WHERE thread_id = $1
|
||||
`,
|
||||
threadId,
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE handmade_post
|
||||
SET deleted = TRUE
|
||||
WHERE
|
||||
id = $1
|
||||
`,
|
||||
postId,
|
||||
)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to mark forum post as deleted"))
|
||||
}
|
||||
|
||||
err = FixThreadPostIds(ctx, tx, threadId)
|
||||
if err != nil {
|
||||
if errors.Is(err, errThreadEmpty) {
|
||||
panic("it shouldn't be possible to delete the last remaining post in a thread, without it also being the first post in the thread and thus resulting in the whole thread getting deleted earlier")
|
||||
} else {
|
||||
panic(oops.New(err, "failed to fix up thread post ids"))
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func CreatePostVersion(ctx context.Context, tx pgx.Tx, postId int, unparsedContent string, ipString string, editReason string, editorId *int) (versionId int) {
|
||||
parsed := parsing.ParseMarkdown(unparsedContent, parsing.RealMarkdown)
|
||||
ip := net.ParseIP(ipString)
|
||||
|
||||
const previewMaxLength = 100
|
||||
parsedPlaintext := parsing.ParseMarkdown(unparsedContent, parsing.PlaintextMarkdown)
|
||||
preview := parsedPlaintext
|
||||
if len(preview) > previewMaxLength-1 {
|
||||
preview = preview[:previewMaxLength-1] + "…"
|
||||
}
|
||||
|
||||
// Create post version
|
||||
err := tx.QueryRow(ctx,
|
||||
`
|
||||
INSERT INTO handmade_postversion (post_id, text_raw, text_parsed, ip, date, edit_reason, editor_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
`,
|
||||
postId,
|
||||
unparsedContent,
|
||||
parsed,
|
||||
ip,
|
||||
time.Now(),
|
||||
editReason,
|
||||
editorId,
|
||||
).Scan(&versionId)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to create post version"))
|
||||
}
|
||||
|
||||
// Update post with version id and preview
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE handmade_post
|
||||
SET current_id = $1, preview = $2
|
||||
WHERE id = $3
|
||||
`,
|
||||
versionId,
|
||||
preview,
|
||||
postId,
|
||||
)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to set current post version and preview"))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var errThreadEmpty = errors.New("thread contained no non-deleted posts")
|
||||
|
||||
/*
|
||||
Ensures that the first_id and last_id on the thread are still good.
|
||||
|
||||
Returns errThreadEmpty if the thread contains no visible posts any more.
|
||||
You should probably mark the thread as deleted in this case.
|
||||
*/
|
||||
func FixThreadPostIds(ctx context.Context, tx pgx.Tx, threadId int) error {
|
||||
postsIter, err := db.Query(ctx, tx, models.Post{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_post
|
||||
WHERE
|
||||
thread_id = $1
|
||||
AND NOT deleted
|
||||
`,
|
||||
threadId,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to fetch posts when fixing up thread")
|
||||
}
|
||||
|
||||
var firstPost, lastPost *models.Post
|
||||
for _, ipost := range postsIter.ToSlice() {
|
||||
post := ipost.(*models.Post)
|
||||
|
||||
if firstPost == nil || post.PostDate.Before(firstPost.PostDate) {
|
||||
firstPost = post
|
||||
}
|
||||
if lastPost == nil || post.PostDate.After(lastPost.PostDate) {
|
||||
lastPost = post
|
||||
}
|
||||
}
|
||||
|
||||
if firstPost == nil || lastPost == nil {
|
||||
return errThreadEmpty
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE handmade_thread
|
||||
SET first_id = $1, last_id = $2
|
||||
WHERE id = $3
|
||||
`,
|
||||
firstPost.ID,
|
||||
lastPost.ID,
|
||||
threadId,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to update thread first/last ids")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue