Rework the category/thread data model

Threads can stand alone now. Threads can be attached to resources
directly without requiring a category. In addition, a lot of wiki stuff
and library discussion stuff was deleted because we're not gonna port
it.
This commit is contained in:
Ben Visness 2021-07-29 22:40:47 -05:00
parent 15ff1de6fc
commit 8ecb4a7173
41 changed files with 788 additions and 1294 deletions

View File

@ -4,14 +4,10 @@ const TimelineTypes = {
FORUM_REPLY: 2,
BLOG_POST: 3,
BLOG_COMMENT: 4,
WIKI_CREATE: 5,
WIKI_EDIT: 6,
WIKI_TALK: 7,
LIBRARY_COMMENT: 8,
SNIPPET_IMAGE: 9,
SNIPPET_VIDEO: 10,
SNIPPET_AUDIO: 11,
SNIPPET_YOUTUBE: 12
SNIPPET_IMAGE: 5,
SNIPPET_VIDEO: 6,
SNIPPET_AUDIO: 7,
SNIPPET_YOUTUBE: 8
};
const showcaseItemTemplate = makeTemplateCloner("showcase_item");

View File

@ -8634,11 +8634,6 @@ input[type=submit] {
padding-left: 10px;
max-width: 80em; }
.wiki .post {
padding: 0;
margin: auto;
max-width: 70em; }
.post .contents h1, .post .contents h2 {
margin: 20px 0px; }
@ -8750,56 +8745,11 @@ input[type=submit] {
.blog .post-list .post:nth-child(even) {
background-color: transparent; }
.wiki .post p {
margin: 10px 0px; }
.wiki .toc {
border-color: #aaa;
border-color: var(--wiki-border-color);
border-left-width: 1px; }
.wiki .toc .toc-number {
color: #333;
color: var(--wiki-toc-number-color); }
.wiki .toc ul {
list-style-type: none;
margin-left: 10px;
margin-bottom: 5px; }
.wiki .toc li {
margin-left: 0px; }
.wiki .aside {
border-color: #aaa;
border-color: var(--wiki-border-color);
border-left-width: 1px;
margin-left: 20px; }
.wiki .aside::before {
margin-left: -20px;
margin-right: 15px;
display: inline-block;
width: 10px;
content: "\21b4 "; }
.wiki .aside .aside-heading {
padding: 2px;
margin: 1px;
border-radius: 3px;
border-width: 0px;
cursor: pointer;
display: inline;
background-color: transparent; }
.wiki .aside > .aside-body {
overflow: hidden;
padding-left: 10px; }
.wiki .aside.folded::before {
content: "\2192 "; }
.wiki .aside.folded > .aside-body {
max-height: 0px; }
.featured-post .meta .avatar-icon {
left: -60px;
bottom: -5px; }
.blog .body blockquote,
.wiki .body blockquote {
.blog .body blockquote {
padding-top: 1px;
padding-bottom: 1px; }
@ -9446,9 +9396,6 @@ span.icon-rss::before {
.timeline.no-blogs .blogs {
display: none; }
.timeline.no-wiki .wiki {
display: none; }
.timeline.no-library .library {
display: none; }

View File

@ -300,8 +300,6 @@ will throw an error.
--irc-users-popout-background: #181818;
--irc-users-popout-border-color-left: #444;
--irc-users-popout-border-color-right: #333;
--wiki-border-color: #444;
--wiki-toc-number-color: #bbb;
--code-line-number-color: #444;
--library-star-btn-background: #252525;
--library-star-btn-border-color: #bbb;

View File

@ -318,8 +318,6 @@ will throw an error.
--irc-users-popout-background: #fff;
--irc-users-popout-border-color-left: #bbb;
--irc-users-popout-border-color-right: #ccc;
--wiki-border-color: #aaa;
--wiki-toc-number-color: #333;
--code-line-number-color: #777;
--library-star-btn-background: #fff;
--library-star-btn-border-color: #999;

View File

@ -22,7 +22,7 @@ import (
Values of these kinds are ok to query even if they are not directly understood by pgtype.
This is common for custom types like:
type CategoryKind int
type ThreadType int
*/
var queryableKinds = []reflect.Kind{
reflect.Int,

View File

@ -145,22 +145,22 @@ func TestPodcastRSS(t *testing.T) {
AssertRegexMatch(t, BuildPodcastRSS(""), RegexPodcastRSS, nil)
}
func TestForumCategory(t *testing.T) {
AssertRegexMatch(t, BuildForumCategory("", nil, 1), RegexForumCategory, nil)
AssertRegexMatch(t, BuildForumCategory("", []string{"wip"}, 2), RegexForumCategory, map[string]string{"cats": "wip", "page": "2"})
AssertRegexMatch(t, BuildForumCategory("", []string{"sub", "wip"}, 2), RegexForumCategory, map[string]string{"cats": "sub/wip", "page": "2"})
AssertSubdomain(t, BuildForumCategory("hmn", nil, 1), "")
AssertSubdomain(t, BuildForumCategory("", nil, 1), "")
AssertSubdomain(t, BuildForumCategory("hero", nil, 1), "hero")
assert.Panics(t, func() { BuildForumCategory("", nil, 0) })
assert.Panics(t, func() { BuildForumCategory("", []string{"", "wip"}, 1) })
assert.Panics(t, func() { BuildForumCategory("", []string{" ", "wip"}, 1) })
assert.Panics(t, func() { BuildForumCategory("", []string{"wip/jobs"}, 1) })
func TestForum(t *testing.T) {
AssertRegexMatch(t, BuildForum("", nil, 1), RegexForum, nil)
AssertRegexMatch(t, BuildForum("", []string{"wip"}, 2), RegexForum, map[string]string{"subforums": "wip", "page": "2"})
AssertRegexMatch(t, BuildForum("", []string{"sub", "wip"}, 2), RegexForum, map[string]string{"subforums": "sub/wip", "page": "2"})
AssertSubdomain(t, BuildForum("hmn", nil, 1), "")
AssertSubdomain(t, BuildForum("", nil, 1), "")
AssertSubdomain(t, BuildForum("hero", nil, 1), "hero")
assert.Panics(t, func() { BuildForum("", nil, 0) })
assert.Panics(t, func() { BuildForum("", []string{"", "wip"}, 1) })
assert.Panics(t, func() { BuildForum("", []string{" ", "wip"}, 1) })
assert.Panics(t, func() { BuildForum("", []string{"wip/jobs"}, 1) })
}
func TestForumNewThread(t *testing.T) {
AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}, false), RegexForumNewThread, map[string]string{"cats": "sub/wip"})
AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}, true), RegexForumNewThreadSubmit, map[string]string{"cats": "sub/wip"})
AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}, false), RegexForumNewThread, map[string]string{"subforums": "sub/wip"})
AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}, true), RegexForumNewThreadSubmit, map[string]string{"subforums": "sub/wip"})
}
func TestForumThread(t *testing.T) {
@ -197,12 +197,6 @@ func TestForumPostReply(t *testing.T) {
AssertSubdomain(t, BuildForumPostReply("hero", nil, 1, 2), "hero")
}
func TestForumPostQuote(t *testing.T) {
AssertRegexMatch(t, BuildForumPostQuote("", nil, 1, 2), RegexForumPostQuote, map[string]string{"threadid": "1", "postid": "2"})
AssertRegexNoMatch(t, BuildForumPostQuote("", nil, 1, 2), RegexForumPost)
AssertSubdomain(t, BuildForumPostQuote("hero", nil, 1, 2), "hero")
}
func TestBlog(t *testing.T) {
AssertRegexMatch(t, BuildBlog("", 1), RegexBlog, nil)
AssertRegexMatch(t, BuildBlog("", 2), RegexBlog, map[string]string{"page": "2"})
@ -248,82 +242,6 @@ func TestBlogPostQuote(t *testing.T) {
AssertSubdomain(t, BuildBlogPostQuote("hero", 1, 2), "hero")
}
func TestWiki(t *testing.T) {
AssertRegexMatch(t, BuildWiki(""), RegexWiki, nil)
AssertSubdomain(t, BuildWiki("hero"), "hero")
}
func TestWikiIndex(t *testing.T) {
AssertRegexMatch(t, BuildWikiIndex(""), RegexWikiIndex, nil)
AssertSubdomain(t, BuildWikiIndex("hero"), "hero")
}
func TestWikiArticle(t *testing.T) {
AssertRegexMatch(t, BuildWikiArticle("", 1, ""), RegexWikiArticle, map[string]string{"articleid": "1"})
AssertRegexMatch(t, BuildWikiArticle("", 1, "wiki/title/--"), RegexWikiArticle, map[string]string{"articleid": "1"})
AssertRegexMatch(t, BuildWikiArticleWithSectionName("", 1, "wiki/title/--", "Hello world"), RegexWikiArticle, map[string]string{"articleid": "1"})
AssertSubdomain(t, BuildWikiArticle("hero", 1, ""), "hero")
}
func TestWikiArticleEdit(t *testing.T) {
AssertRegexMatch(t, BuildWikiArticleEdit("", 1), RegexWikiArticleEdit, map[string]string{"articleid": "1"})
AssertSubdomain(t, BuildWikiArticleEdit("hero", 1), "hero")
}
func TestWikiArticleDelete(t *testing.T) {
AssertRegexMatch(t, BuildWikiArticleDelete("", 1), RegexWikiArticleDelete, map[string]string{"articleid": "1"})
AssertSubdomain(t, BuildWikiArticleDelete("hero", 1), "hero")
}
func TestWikiArticleHistory(t *testing.T) {
AssertRegexMatch(t, BuildWikiArticleHistory("", 1, ""), RegexWikiArticleHistory, map[string]string{"articleid": "1"})
AssertRegexMatch(t, BuildWikiArticleHistory("", 1, "wiki/title/--"), RegexWikiArticleHistory, map[string]string{"articleid": "1"})
AssertSubdomain(t, BuildWikiArticleHistory("hero", 1, ""), "hero")
}
func TestWikiTalk(t *testing.T) {
AssertRegexMatch(t, BuildWikiTalk("", 1, ""), RegexWikiTalk, map[string]string{"articleid": "1"})
AssertRegexMatch(t, BuildWikiTalk("", 1, "wiki/title/--"), RegexWikiTalk, map[string]string{"articleid": "1"})
AssertSubdomain(t, BuildWikiTalk("hero", 1, ""), "hero")
}
func TestWikiRevision(t *testing.T) {
AssertRegexMatch(t, BuildWikiRevision("", 1, "", 2), RegexWikiRevision, map[string]string{"articleid": "1", "revisionid": "2"})
AssertRegexMatch(t, BuildWikiRevision("", 1, "wiki/title/--", 2), RegexWikiRevision, map[string]string{"articleid": "1", "revisionid": "2"})
AssertSubdomain(t, BuildWikiRevision("hero", 1, "", 2), "hero")
}
func TestWikiDiff(t *testing.T) {
AssertRegexMatch(t, BuildWikiDiff("", 1, "", 2, 3), RegexWikiDiff, map[string]string{"articleid": "1", "revisionidold": "2", "revisionidnew": "3"})
AssertRegexMatch(t, BuildWikiDiff("", 1, "wiki/title", 2, 3), RegexWikiDiff, map[string]string{"articleid": "1", "revisionidold": "2", "revisionidnew": "3"})
AssertSubdomain(t, BuildWikiDiff("hero", 1, "wiki/title", 2, 3), "hero")
}
func TestWikiTalkPost(t *testing.T) {
AssertRegexMatch(t, BuildWikiTalkPost("", 1, 2), RegexWikiTalkPost, map[string]string{"articleid": "1", "postid": "2"})
AssertSubdomain(t, BuildWikiTalkPost("hero", 1, 2), "hero")
}
func TestWikiTalkPostDelete(t *testing.T) {
AssertRegexMatch(t, BuildWikiTalkPostDelete("", 1, 2), RegexWikiTalkPostDelete, map[string]string{"articleid": "1", "postid": "2"})
AssertSubdomain(t, BuildWikiTalkPostDelete("hero", 1, 2), "hero")
}
func TestWikiTalkPostEdit(t *testing.T) {
AssertRegexMatch(t, BuildWikiTalkPostEdit("", 1, 2), RegexWikiTalkPostEdit, map[string]string{"articleid": "1", "postid": "2"})
AssertSubdomain(t, BuildWikiTalkPostEdit("hero", 1, 2), "hero")
}
func TestWikiTalkPostReply(t *testing.T) {
AssertRegexMatch(t, BuildWikiTalkPostReply("", 1, 2), RegexWikiTalkPostReply, map[string]string{"articleid": "1", "postid": "2"})
AssertSubdomain(t, BuildWikiTalkPostReply("hero", 1, 2), "hero")
}
func TestWikiTalkPostQuote(t *testing.T) {
AssertRegexMatch(t, BuildWikiTalkPostQuote("", 1, 2), RegexWikiTalkPostQuote, map[string]string{"articleid": "1", "postid": "2"})
AssertSubdomain(t, BuildWikiTalkPostQuote("hero", 1, 2), "hero")
}
func TestLibrary(t *testing.T) {
AssertRegexMatch(t, BuildLibrary(""), RegexLibrary, nil)
AssertSubdomain(t, BuildLibrary("hero"), "hero")
@ -344,38 +262,6 @@ func TestLibraryResource(t *testing.T) {
AssertSubdomain(t, BuildLibraryResource("hero", 1), "hero")
}
func TestLibraryDiscussion(t *testing.T) {
AssertRegexMatch(t, BuildLibraryDiscussion("", 1, 2, 1), RegexLibraryDiscussion, map[string]string{"resourceid": "1", "threadid": "2"})
AssertRegexMatch(t, BuildLibraryDiscussion("", 1, 2, 3), RegexLibraryDiscussion, map[string]string{"resourceid": "1", "threadid": "2", "page": "3"})
AssertRegexMatch(t, BuildLibraryDiscussionWithPostHash("", 1, 2, 3, 123), RegexLibraryDiscussion, map[string]string{"resourceid": "1", "threadid": "2", "page": "3"})
AssertSubdomain(t, BuildLibraryDiscussion("hero", 1, 2, 3), "hero")
}
func TestLibraryPost(t *testing.T) {
AssertRegexMatch(t, BuildLibraryPost("", 1, 2, 3), RegexLibraryPost, map[string]string{"resourceid": "1", "threadid": "2", "postid": "3"})
AssertSubdomain(t, BuildLibraryPost("hero", 1, 2, 3), "hero")
}
func TestLibraryPostDelete(t *testing.T) {
AssertRegexMatch(t, BuildLibraryPostDelete("", 1, 2, 3), RegexLibraryPostDelete, map[string]string{"resourceid": "1", "threadid": "2", "postid": "3"})
AssertSubdomain(t, BuildLibraryPostDelete("hero", 1, 2, 3), "hero")
}
func TestLibraryPostEdit(t *testing.T) {
AssertRegexMatch(t, BuildLibraryPostEdit("", 1, 2, 3), RegexLibraryPostEdit, map[string]string{"resourceid": "1", "threadid": "2", "postid": "3"})
AssertSubdomain(t, BuildLibraryPostEdit("hero", 1, 2, 3), "hero")
}
func TestLibraryPostReply(t *testing.T) {
AssertRegexMatch(t, BuildLibraryPostReply("", 1, 2, 3), RegexLibraryPostReply, map[string]string{"resourceid": "1", "threadid": "2", "postid": "3"})
AssertSubdomain(t, BuildLibraryPostReply("hero", 1, 2, 3), "hero")
}
func TestLibraryPostQuote(t *testing.T) {
AssertRegexMatch(t, BuildLibraryPostQuote("", 1, 2, 3), RegexLibraryPostQuote, map[string]string{"resourceid": "1", "threadid": "2", "postid": "3"})
AssertSubdomain(t, BuildLibraryPostQuote("hero", 1, 2, 3), "hero")
}
func TestProjectCSS(t *testing.T) {
AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil)
}
@ -395,8 +281,8 @@ func TestPublic(t *testing.T) {
AssertRegexMatch(t, BuildUserFile("mylogo.png"), RegexPublic, nil)
}
func TestForumCategoryMarkRead(t *testing.T) {
AssertRegexMatch(t, BuildForumCategoryMarkRead(5), RegexForumCategoryMarkRead, map[string]string{"catid": "5"})
func TestForumMarkRead(t *testing.T) {
AssertRegexMatch(t, BuildForumMarkRead(5), RegexForumMarkRead, map[string]string{"sfid": "5"})
}
func AssertSubdomain(t *testing.T, fullUrl string, expectedSubdomain string) {
@ -468,3 +354,9 @@ func AssertRegexNoMatch(t *testing.T, fullUrl string, regex *regexp.Regexp) {
match := regex.FindStringSubmatch(requestPath)
assert.Nilf(t, match, "Url matched regex: [%s] vs [%s]", requestPath, regex.String())
}
func TestThingsThatDontNeedCoverage(t *testing.T) {
// look the other way ಠ_ಠ
BuildPodcastEpisodeFile("foo", "bar")
BuildS3Asset("ha ha")
}

View File

@ -305,17 +305,17 @@ func BuildPodcastEpisodeFile(projectSlug string, filename string) string {
* Forums
*/
// TODO(asaf): This also matches urls generated by BuildForumThread (/t/ is identified as a cat, and the threadid as a page)
// This shouldn't be a problem since we will match Thread before Category in the router, but should we enforce it here?
var RegexForumCategory = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?(/(?P<page>\d+))?$`)
// TODO(asaf): This also matches urls generated by BuildForumThread (/t/ is identified as a subforum, and the threadid as a page)
// This shouldn't be a problem since we will match Thread before Subforum in the router, but should we enforce it here?
var RegexForum = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?(/(?P<page>\d+))?$`)
func BuildForumCategory(projectSlug string, subforums []string, page int) string {
func BuildForum(projectSlug string, subforums []string, page int) string {
defer CatchPanic()
if page < 1 {
panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page))
}
builder := buildForumCategoryPath(subforums)
builder := buildSubforumPath(subforums)
if page > 1 {
builder.WriteRune('/')
@ -325,12 +325,12 @@ func BuildForumCategory(projectSlug string, subforums []string, page int) string
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexForumNewThread = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/new$`)
var RegexForumNewThreadSubmit = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/new/submit$`)
var RegexForumNewThread = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/new$`)
var RegexForumNewThreadSubmit = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/new/submit$`)
func BuildForumNewThread(projectSlug string, subforums []string, submit bool) string {
defer CatchPanic()
builder := buildForumCategoryPath(subforums)
builder := buildSubforumPath(subforums)
builder.WriteString("/t/new")
if submit {
builder.WriteString("/submit")
@ -339,7 +339,7 @@ func BuildForumNewThread(projectSlug string, subforums []string, submit bool) st
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexForumThread = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)(-([^/]+))?(/(?P<page>\d+))?$`)
var RegexForumThread = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)(-([^/]+))?(/(?P<page>\d+))?$`)
func BuildForumThread(projectSlug string, subforums []string, threadId int, title string, page int) string {
defer CatchPanic()
@ -355,7 +355,7 @@ func BuildForumThreadWithPostHash(projectSlug string, subforums []string, thread
return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId))
}
var RegexForumPost = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)$`)
var RegexForumPost = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)$`)
func BuildForumPost(projectSlug string, subforums []string, threadId int, postId int) string {
defer CatchPanic()
@ -364,7 +364,7 @@ func BuildForumPost(projectSlug string, subforums []string, threadId int, postId
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexForumPostDelete = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/delete$`)
var RegexForumPostDelete = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/delete$`)
func BuildForumPostDelete(projectSlug string, subforums []string, threadId int, postId int) string {
defer CatchPanic()
@ -373,7 +373,7 @@ func BuildForumPostDelete(projectSlug string, subforums []string, threadId int,
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexForumPostEdit = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/edit$`)
var RegexForumPostEdit = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/edit$`)
func BuildForumPostEdit(projectSlug string, subforums []string, threadId int, postId int) string {
defer CatchPanic()
@ -382,7 +382,7 @@ func BuildForumPostEdit(projectSlug string, subforums []string, threadId int, po
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexForumPostReply = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/reply$`)
var RegexForumPostReply = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/reply$`)
// TODO: It's kinda weird that we have "replies" to a specific post. That's not how the data works. I guess this just affects what you see as the "post you're replying to" on the post composer page?
func BuildForumPostReply(projectSlug string, subforums []string, threadId int, postId int) string {
@ -472,163 +472,6 @@ func BuildBlogPostQuote(projectSlug string, threadId int, postId int) string {
return ProjectUrl(builder.String(), nil, projectSlug)
}
/*
* Wiki
*/
var RegexWiki = regexp.MustCompile(`^/wiki$`)
func BuildWiki(projectSlug string) string {
defer CatchPanic()
return ProjectUrl("/wiki", nil, projectSlug)
}
var RegexWikiIndex = regexp.MustCompile(`^/wiki/index$`)
func BuildWikiIndex(projectSlug string) string {
defer CatchPanic()
return ProjectUrl("/wiki/index", nil, projectSlug)
}
var RegexWikiArticle = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)(-([^/])+)?$`)
func BuildWikiArticle(projectSlug string, articleId int, title string) string {
defer CatchPanic()
builder := buildWikiArticlePath(articleId, title)
return ProjectUrl(builder.String(), nil, projectSlug)
}
func BuildWikiArticleWithSectionName(projectSlug string, articleId int, title string, sectionName string) string {
defer CatchPanic()
builder := buildWikiArticlePath(articleId, title)
return ProjectUrlWithFragment(builder.String(), nil, projectSlug, sectionName)
}
var RegexWikiArticleEdit = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)/edit$`)
func BuildWikiArticleEdit(projectSlug string, articleId int) string {
defer CatchPanic()
builder := buildWikiArticlePath(articleId, "")
builder.WriteString("/edit")
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexWikiArticleDelete = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)/delete$`)
func BuildWikiArticleDelete(projectSlug string, articleId int) string {
defer CatchPanic()
builder := buildWikiArticlePath(articleId, "")
builder.WriteString("/delete")
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexWikiArticleHistory = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)(-([^/])+)?/history$`)
func BuildWikiArticleHistory(projectSlug string, articleId int, title string) string {
defer CatchPanic()
builder := buildWikiArticlePath(articleId, title)
builder.WriteString("/history")
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexWikiTalk = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)(-([^/])+)?/talk$`)
func BuildWikiTalk(projectSlug string, articleId int, title string) string {
defer CatchPanic()
builder := buildWikiArticlePath(articleId, title)
builder.WriteString("/talk")
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexWikiRevision = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)(-([^/])+)?/(?P<revisionid>\d+)$`)
func BuildWikiRevision(projectSlug string, articleId int, title string, revisionId int) string {
defer CatchPanic()
if revisionId < 1 {
panic(oops.New(nil, "Invalid wiki revision id (%d), must be >= 1", revisionId))
}
builder := buildWikiArticlePath(articleId, title)
builder.WriteRune('/')
builder.WriteString(strconv.Itoa(revisionId))
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexWikiDiff = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)(-([^/])+)?/diff/(?P<revisionidold>\d+)/(?P<revisionidnew>\d+)$`)
func BuildWikiDiff(projectSlug string, articleId int, title string, revisionIdOld int, revisionIdNew int) string {
defer CatchPanic()
if revisionIdOld < 1 {
panic(oops.New(nil, "Invalid wiki revision id (%d), must be >= 1", revisionIdOld))
}
if revisionIdNew < 1 {
panic(oops.New(nil, "Invalid wiki revision id (%d), must be >= 1", revisionIdNew))
}
builder := buildWikiArticlePath(articleId, title)
builder.WriteString("/diff/")
builder.WriteString(strconv.Itoa(revisionIdOld))
builder.WriteRune('/')
builder.WriteString(strconv.Itoa(revisionIdNew))
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexWikiTalkPost = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)/talk/(?P<postid>\d+)$`)
func BuildWikiTalkPost(projectSlug string, articleId int, postId int) string {
defer CatchPanic()
builder := buildWikiTalkPath(articleId, postId)
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexWikiTalkPostDelete = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)/talk/(?P<postid>\d+)/delete$`)
func BuildWikiTalkPostDelete(projectSlug string, articleId int, postId int) string {
defer CatchPanic()
builder := buildWikiTalkPath(articleId, postId)
builder.WriteString("/delete")
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexWikiTalkPostEdit = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)/talk/(?P<postid>\d+)/edit$`)
func BuildWikiTalkPostEdit(projectSlug string, articleId int, postId int) string {
defer CatchPanic()
builder := buildWikiTalkPath(articleId, postId)
builder.WriteString("/edit")
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexWikiTalkPostReply = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)/talk/(?P<postid>\d+)/reply$`)
func BuildWikiTalkPostReply(projectSlug string, articleId int, postId int) string {
defer CatchPanic()
builder := buildWikiTalkPath(articleId, postId)
builder.WriteString("/reply")
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexWikiTalkPostQuote = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)/talk/(?P<postid>\d+)/quote$`)
func BuildWikiTalkPostQuote(projectSlug string, articleId int, postId int) string {
defer CatchPanic()
builder := buildWikiTalkPath(articleId, postId)
builder.WriteString("/quote")
return ProjectUrl(builder.String(), nil, projectSlug)
}
/*
* Library
*/
@ -671,74 +514,6 @@ func BuildLibraryResource(projectSlug string, resourceId int) string {
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexLibraryDiscussion = regexp.MustCompile(`^/library/resource/(?P<resourceid>\d+)/d/(?P<threadid>\d+)(/(?P<page>\d+))?$`)
func BuildLibraryDiscussion(projectSlug string, resourceId int, threadId int, page int) string {
defer CatchPanic()
builder := buildLibraryDiscussionPath(resourceId, threadId, page)
return ProjectUrl(builder.String(), nil, projectSlug)
}
func BuildLibraryDiscussionWithPostHash(projectSlug string, resourceId int, threadId int, page int, postId int) string {
defer CatchPanic()
if postId < 1 {
panic(oops.New(nil, "Invalid library post ID (%d), must be >= 1", postId))
}
builder := buildLibraryDiscussionPath(resourceId, threadId, page)
return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId))
}
var RegexLibraryPost = regexp.MustCompile(`^/library/resource/(?P<resourceid>\d+)/d/(?P<threadid>\d+)/p/(?P<postid>\d+)$`)
func BuildLibraryPost(projectSlug string, resourceId int, threadId int, postId int) string {
defer CatchPanic()
builder := buildLibraryPostPath(resourceId, threadId, postId)
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexLibraryPostDelete = regexp.MustCompile(`^/library/resource/(?P<resourceid>\d+)/d/(?P<threadid>\d+)/p/(?P<postid>\d+)/delete$`)
func BuildLibraryPostDelete(projectSlug string, resourceId int, threadId int, postId int) string {
defer CatchPanic()
builder := buildLibraryPostPath(resourceId, threadId, postId)
builder.WriteString("/delete")
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexLibraryPostEdit = regexp.MustCompile(`^/library/resource/(?P<resourceid>\d+)/d/(?P<threadid>\d+)/p/(?P<postid>\d+)/edit$`)
func BuildLibraryPostEdit(projectSlug string, resourceId int, threadId int, postId int) string {
defer CatchPanic()
builder := buildLibraryPostPath(resourceId, threadId, postId)
builder.WriteString("/edit")
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexLibraryPostReply = regexp.MustCompile(`^/library/resource/(?P<resourceid>\d+)/d/(?P<threadid>\d+)/p/(?P<postid>\d+)/reply$`)
func BuildLibraryPostReply(projectSlug string, resourceId int, threadId int, postId int) string {
defer CatchPanic()
builder := buildLibraryPostPath(resourceId, threadId, postId)
builder.WriteString("/reply")
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexLibraryPostQuote = regexp.MustCompile(`^/library/resource/(?P<resourceid>\d+)/d/(?P<threadid>\d+)/p/(?P<postid>\d+)/quote$`)
func BuildLibraryPostQuote(projectSlug string, resourceId int, threadId int, postId int) string {
defer CatchPanic()
builder := buildLibraryPostPath(resourceId, threadId, postId)
builder.WriteString("/quote")
return ProjectUrl(builder.String(), nil, projectSlug)
}
/*
* Assets
*/
@ -803,18 +578,18 @@ func BuildUserFile(filepath string) string {
* Other
*/
var RegexForumCategoryMarkRead = regexp.MustCompile(`^/markread/(?P<catid>\d+)$`)
var RegexForumMarkRead = regexp.MustCompile(`^/markread/(?P<sfid>\d+)$`)
// NOTE(asaf): categoryId == 0 means ALL CATEGORIES
func BuildForumCategoryMarkRead(categoryId int) string {
// NOTE(asaf): subforumId == 0 means ALL SUBFORUMS
func BuildForumMarkRead(subforumId int) string {
defer CatchPanic()
if categoryId < 0 {
panic(oops.New(nil, "Invalid category ID (%d), must be >= 0", categoryId))
if subforumId < 0 {
panic(oops.New(nil, "Invalid subforum ID (%d), must be >= 0", subforumId))
}
var builder strings.Builder
builder.WriteString("/markread/")
builder.WriteString(strconv.Itoa(categoryId))
builder.WriteString(strconv.Itoa(subforumId))
return Url(builder.String(), nil)
}
@ -825,7 +600,7 @@ var RegexCatchAll = regexp.MustCompile("")
* Helper functions
*/
func buildForumCategoryPath(subforums []string) *strings.Builder {
func buildSubforumPath(subforums []string) *strings.Builder {
for _, subforum := range subforums {
if strings.Contains(subforum, "/") {
panic(oops.New(nil, "Tried building forum url with / in subforum name"))
@ -855,7 +630,7 @@ func buildForumThreadPath(subforums []string, threadId int, title string, page i
panic(oops.New(nil, "Invalid forum thread ID (%d), must be >= 1", threadId))
}
builder := buildForumCategoryPath(subforums)
builder := buildSubforumPath(subforums)
builder.WriteString("/t/")
builder.WriteString(strconv.Itoa(threadId))
@ -880,7 +655,7 @@ func buildForumPostPath(subforums []string, threadId int, postId int) *strings.B
panic(oops.New(nil, "Invalid forum post ID (%d), must be >= 1", postId))
}
builder := buildForumCategoryPath(subforums)
builder := buildSubforumPath(subforums)
builder.WriteString("/t/")
builder.WriteString(strconv.Itoa(threadId))
@ -934,34 +709,6 @@ func buildBlogPostPath(threadId int, postId int) *strings.Builder {
return &builder
}
func buildWikiArticlePath(articleId int, title string) *strings.Builder {
if articleId < 1 {
panic(oops.New(nil, "Invalid wiki article ID (%d), must be >= 1", articleId))
}
var builder strings.Builder
builder.WriteString("/wiki/")
builder.WriteString(strconv.Itoa(articleId))
if len(title) > 0 {
builder.WriteRune('-')
builder.WriteString(PathSafeTitle(title))
}
return &builder
}
func buildWikiTalkPath(articleId int, postId int) *strings.Builder {
if postId < 1 {
panic(oops.New(nil, "Invalid wiki post ID (%d), must be >= 1", postId))
}
builder := buildWikiArticlePath(articleId, "")
builder.WriteString("/talk/")
builder.WriteString(strconv.Itoa(postId))
return builder
}
func buildLibraryResourcePath(resourceId int) *strings.Builder {
if resourceId < 1 {
panic(oops.New(nil, "Invalid library resource ID (%d), must be >= 1", resourceId))

View File

@ -5,7 +5,6 @@ import (
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)

View File

@ -0,0 +1,160 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(ReworkThreads{})
}
type ReworkThreads struct{}
func (m ReworkThreads) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 7, 28, 2, 0, 0, 0, time.UTC))
}
func (m ReworkThreads) Name() string {
return "ReworkThreads"
}
func (m ReworkThreads) Description() string {
return "Detach threads from categories and make them more independent"
}
func (m ReworkThreads) Up(ctx context.Context, tx pgx.Tx) error {
// add and rename columns
_, err := tx.Exec(ctx, `
ALTER TABLE handmade_thread
ADD type INT,
ADD project_id INT REFERENCES handmade_project (id) ON DELETE RESTRICT, -- used to associate project articles
ALTER category_id DROP NOT NULL,
ADD personal_article_user_id INT REFERENCES auth_user (id) ON DELETE RESTRICT; -- used to associate personal articles
ALTER TABLE handmade_thread
RENAME category_id TO subforum_id; -- preemptive, we're renaming categories next
ALTER TABLE handmade_post
RENAME category_kind TO thread_type;
ALTER TABLE handmade_post
DROP category_id,
DROP CONSTRAINT post_category_kind_from_category,
DROP CONSTRAINT post_project_id_from_category;
DROP FUNCTION category_id_for_thread(int);
DROP FUNCTION category_kind_for_post(int);
DROP FUNCTION project_id_for_post(int);
`)
if err != nil {
return oops.New(err, "failed to add and rename columns")
}
// fill out null thread fields
_, err = tx.Exec(ctx, `
UPDATE handmade_thread AS thread
SET (type, project_id, subforum_id) = (
SELECT kind, project_id, CASE WHEN cat.kind = 2 THEN cat.id ELSE NULL END
FROM handmade_category AS cat
WHERE cat.id = thread.subforum_id
);
ALTER TABLE handmade_thread
ALTER type SET NOT NULL,
ALTER project_id SET NOT NULL;
`)
if err != nil {
return oops.New(err, "failed to copy category kind to thread type")
}
// move wiki posts to personal articles
_, err = tx.Exec(ctx, `
-- turn wiki threads into personal articles
UPDATE handmade_thread
SET
type = 7, -- new "personal article" type
personal_article_user_id = 1979 -- assign to Ben for now
WHERE type = 5;
-- update the denormalized field on posts
UPDATE handmade_post
SET thread_type = 7
WHERE thread_type = 5;
`)
if err != nil {
return oops.New(err, "failed to turn wiki posts into personal articles")
}
// delete talk pages
_, err = tx.Exec(ctx, `
DELETE FROM handmade_post
WHERE
thread_type = 7 -- personal articles, see above
AND parent_id IS NOT NULL;
UPDATE handmade_thread
SET last_id = first_id
WHERE type = 7;
`)
if err != nil {
return oops.New(err, "failed to delete wiki talk pages")
}
// delete library discussions
_, err = tx.Exec(ctx, `
DELETE FROM handmade_threadlastreadinfo
WHERE thread_id IN (
SELECT id
FROM handmade_thread
WHERE type = 6
);
DELETE FROM handmade_thread
WHERE type = 6;
DELETE FROM handmade_post
WHERE thread_type = 6;
ALTER TABLE handmade_libraryresource
DROP category_id;
`)
if err != nil {
return oops.New(err, "failed to delete library discussions")
}
// delete references to weirdo categories
_, err = tx.Exec(ctx, `
ALTER TABLE handmade_project
DROP blog_id,
DROP annotation_id,
DROP wiki_id;
`)
if err != nil {
return oops.New(err, "failed to delete references to categories from projects")
}
// delete categories we no longer need
_, err = tx.Exec(ctx, `
DELETE FROM handmade_categorylastreadinfo
WHERE category_id IN (
SELECT id
FROM handmade_category
WHERE kind != 2
);
DELETE FROM handmade_category
WHERE kind != 2;
`)
if err != nil {
return oops.New(err, "failed to delete categories")
}
return nil
}
func (m ReworkThreads) Down(ctx context.Context, tx pgx.Tx) error {
panic("Implement me")
}

View File

@ -0,0 +1,88 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(AddThreadAndPostConstraints{})
}
type AddThreadAndPostConstraints struct{}
func (m AddThreadAndPostConstraints) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 7, 28, 3, 36, 4, 0, time.UTC))
}
func (m AddThreadAndPostConstraints) Name() string {
return "AddThreadAndPostConstraints"
}
func (m AddThreadAndPostConstraints) Description() string {
return "Add back appropriate check constraints for the new thread model"
}
func (m AddThreadAndPostConstraints) Up(ctx context.Context, tx pgx.Tx) error {
// create null check constraints for threads
_, err := tx.Exec(ctx, `
ALTER TABLE handmade_thread
ADD CONSTRAINT thread_has_field_for_type CHECK (
CASE
WHEN type = 1 THEN
subforum_id IS NULL
AND personal_article_user_id IS NULL
WHEN type = 2 THEN
subforum_id IS NOT NULL
AND personal_article_user_id IS NULL
WHEN type = 7 THEN
subforum_id IS NULL
AND personal_article_user_id IS NOT NULL
ELSE TRUE
END
);
`)
if err != nil {
return oops.New(err, "failed to add constraint to threads")
}
// add constraints to posts
_, err = tx.Exec(ctx, `
CREATE FUNCTION thread_type_for_post(int) RETURNS int AS $$
SELECT thread.type
FROM
handmade_post AS post
JOIN handmade_thread AS thread ON post.thread_id = thread.id
WHERE post.id = $1
$$ LANGUAGE SQL;
CREATE FUNCTION project_id_for_post(int) RETURNS int AS $$
SELECT thread.project_id
FROM
handmade_post AS post
JOIN handmade_thread AS thread ON post.thread_id = thread.id
WHERE post.id = $1
$$ LANGUAGE SQL;
ALTER TABLE handmade_post
ADD CONSTRAINT post_thread_type_from_thread CHECK (
thread_type_for_post(id) = thread_type
),
ADD CONSTRAINT post_project_id_from_thread CHECK (
project_id_for_post(id) = project_id
);
`)
if err != nil {
return oops.New(err, "failed to add post constraints")
}
return nil
}
func (m AddThreadAndPostConstraints) Down(ctx context.Context, tx pgx.Tx) error {
panic("Implement me")
}

View File

@ -0,0 +1,60 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(RenameCategoryToSubforum{})
}
type RenameCategoryToSubforum struct{}
func (m RenameCategoryToSubforum) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 7, 28, 4, 0, 0, 0, time.UTC))
}
func (m RenameCategoryToSubforum) Name() string {
return "RenameCategoryToSubforum"
}
func (m RenameCategoryToSubforum) Description() string {
return "Rename categories to subforums"
}
func (m RenameCategoryToSubforum) Up(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
ALTER TABLE handmade_category
RENAME TO handmade_subforum;
ALTER TABLE handmade_subforum
ALTER project_id SET NOT NULL,
ALTER slug SET NOT NULL,
ALTER name SET NOT NULL,
ALTER blurb SET NOT NULL,
ALTER blurb SET DEFAULT '',
DROP kind,
DROP depth,
DROP color_1,
DROP color_2;
ALTER TABLE handmade_categorylastreadinfo
RENAME TO handmade_subforumlastreadinfo;
ALTER TABLE handmade_subforumlastreadinfo
RENAME category_id TO subforum_id;
`)
if err != nil {
return oops.New(err, "failed to rename stuff")
}
return nil
}
func (m RenameCategoryToSubforum) Down(ctx context.Context, tx pgx.Tx) error {
panic("Implement me")
}

View File

@ -1,9 +1,26 @@
package migrations
import "git.handmade.network/hmn/hmn/src/migration/types"
import (
"context"
"fmt"
"git.handmade.network/hmn/hmn/src/migration/types"
"github.com/jackc/pgx/v4"
)
var All map[types.MigrationVersion]types.Migration = make(map[types.MigrationVersion]types.Migration)
func registerMigration(m types.Migration) {
All[m.Version()] = m
}
func debugQuery(ctx context.Context, tx pgx.Tx, sql string) {
rows, err := tx.Query(ctx, sql)
if err != nil {
panic(err)
}
for rows.Next() {
vals, _ := rows.Values()
fmt.Println(vals)
}
}

View File

@ -1,155 +0,0 @@
package models
import (
"context"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4/pgxpool"
)
type CategoryKind int
const (
CatKindBlog CategoryKind = iota + 1
CatKindForum
CatKindStatic
CatKindAnnotation
CatKindWiki
CatKindLibraryResource
)
type Category struct {
ID int `db:"id"`
ParentID *int `db:"parent_id"`
ProjectID *int `db:"project_id"` // TODO: Make not null
Slug *string `db:"slug"` // TODO: Make not null
Name *string `db:"name"` // TODO: Make not null
Blurb *string `db:"blurb"` // TODO: Make not null
Kind CategoryKind `db:"kind"`
Color1 string `db:"color_1"`
Color2 string `db:"color_2"`
Depth int `db:"depth"` // TODO: What is this?
}
type CategoryTree map[int]*CategoryTreeNode
type CategoryTreeNode struct {
Category
Parent *CategoryTreeNode
Children []*CategoryTreeNode
}
func (node *CategoryTreeNode) GetLineage() []*Category {
current := node
length := 0
for current != nil {
current = current.Parent
length += 1
}
result := make([]*Category, length)
current = node
for i := length - 1; i >= 0; i -= 1 {
result[i] = &current.Category
current = current.Parent
}
return result
}
func GetFullCategoryTree(ctx context.Context, conn *pgxpool.Pool) CategoryTree {
type categoryRow struct {
Cat Category `db:"cat"`
}
rows, err := db.Query(ctx, conn, categoryRow{},
`
SELECT $columns
FROM
handmade_category as cat
ORDER BY id ASC
`,
)
if err != nil {
panic(oops.New(err, "Failed to fetch category tree"))
}
rowsSlice := rows.ToSlice()
catTreeMap := make(map[int]*CategoryTreeNode, len(rowsSlice))
for _, row := range rowsSlice {
cat := row.(*categoryRow).Cat
catTreeMap[cat.ID] = &CategoryTreeNode{Category: cat}
}
for _, node := range catTreeMap {
if node.ParentID != nil {
node.Parent = catTreeMap[*node.ParentID]
}
}
for _, row := range rowsSlice {
// NOTE(asaf): Doing this in a separate loop over rowsSlice to ensure that Children are in db order.
cat := row.(*categoryRow).Cat
node := catTreeMap[cat.ID]
if node.Parent != nil {
node.Parent.Children = append(node.Parent.Children, node)
}
}
return catTreeMap
}
type CategoryLineageBuilder struct {
Tree CategoryTree
CategoryCache map[int][]*Category
SlugCache map[int][]string
}
func MakeCategoryLineageBuilder(fullCategoryTree CategoryTree) *CategoryLineageBuilder {
return &CategoryLineageBuilder{
Tree: fullCategoryTree,
CategoryCache: make(map[int][]*Category),
SlugCache: make(map[int][]string),
}
}
func (cl *CategoryLineageBuilder) GetLineage(catId int) []*Category {
_, ok := cl.CategoryCache[catId]
if !ok {
cl.CategoryCache[catId] = cl.Tree[catId].GetLineage()
}
return cl.CategoryCache[catId]
}
func (cl *CategoryLineageBuilder) GetSubforumLineage(catId int) []*Category {
return cl.GetLineage(catId)[1:]
}
func (cl *CategoryLineageBuilder) GetLineageSlugs(catId int) []string {
_, ok := cl.SlugCache[catId]
if !ok {
lineage := cl.GetLineage(catId)
result := make([]string, 0, len(lineage))
for _, cat := range lineage {
name := ""
if cat.Slug != nil {
name = *cat.Slug
}
result = append(result, name)
}
cl.SlugCache[catId] = result
}
return cl.SlugCache[catId]
}
func (cl *CategoryLineageBuilder) GetSubforumLineageSlugs(catId int) []string {
return cl.GetLineageSlugs(catId)[1:]
}
func (cl *CategoryLineageBuilder) FindIdBySlug(projectId int, slug string) int {
for _, node := range cl.Tree {
if node.Slug != nil && *node.Slug == slug && node.ProjectID != nil && *node.ProjectID == projectId {
return node.ID
}
}
return -1
}

View File

@ -3,8 +3,7 @@ package models
type LibraryResource struct {
ID int `db:"id"`
CategoryID int `db:"category_id"`
ProjectID *int `db:"project_id"`
ProjectID *int `db:"project_id"`
Name string `db:"name"`
Description string `db:"description"`

View File

@ -9,14 +9,13 @@ type Post struct {
ID int `db:"id"`
// TODO: Document each of these
AuthorID *int `db:"author_id"`
CategoryID int `db:"category_id"`
ParentID *int `db:"parent_id"`
ThreadID int `db:"thread_id"`
CurrentID int `db:"current_id"`
ProjectID int `db:"project_id"`
AuthorID *int `db:"author_id"`
ParentID *int `db:"parent_id"`
ThreadID int `db:"thread_id"`
CurrentID int `db:"current_id"`
ProjectID int `db:"project_id"`
CategoryKind CategoryKind `db:"category_kind"`
ThreadType ThreadType `db:"thread_type"`
PostDate time.Time `db:"postdate"`
Deleted bool `db:"deleted"`

136
src/models/subforum.go Normal file
View File

@ -0,0 +1,136 @@
package models
import (
"context"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4/pgxpool"
)
type Subforum struct {
ID int `db:"id"`
ParentID *int `db:"parent_id"`
ProjectID int `db:"project_id"`
Slug string `db:"slug"`
Name string `db:"name"`
Blurb string `db:"blurb"`
}
type SubforumTree map[int]*SubforumTreeNode
type SubforumTreeNode struct {
Subforum
Parent *SubforumTreeNode
Children []*SubforumTreeNode
}
func (node *SubforumTreeNode) GetLineage() []*Subforum {
current := node
length := 0
for current != nil {
current = current.Parent
length += 1
}
result := make([]*Subforum, length)
current = node
for i := length - 1; i >= 0; i -= 1 {
result[i] = &current.Subforum
current = current.Parent
}
return result
}
func GetFullSubforumTree(ctx context.Context, conn *pgxpool.Pool) SubforumTree {
type subforumRow struct {
Subforum Subforum `db:"sf"`
}
rows, err := db.Query(ctx, conn, subforumRow{},
`
SELECT $columns
FROM
handmade_subforum as sf
ORDER BY id ASC
`,
)
if err != nil {
panic(oops.New(err, "failed to fetch subforum tree"))
}
rowsSlice := rows.ToSlice()
sfTreeMap := make(map[int]*SubforumTreeNode, len(rowsSlice))
for _, row := range rowsSlice {
sf := row.(*subforumRow).Subforum
sfTreeMap[sf.ID] = &SubforumTreeNode{Subforum: sf}
}
for _, node := range sfTreeMap {
if node.ParentID != nil {
node.Parent = sfTreeMap[*node.ParentID]
}
}
for _, row := range rowsSlice {
// NOTE(asaf): Doing this in a separate loop over rowsSlice to ensure that Children are in db order.
cat := row.(*subforumRow).Subforum
node := sfTreeMap[cat.ID]
if node.Parent != nil {
node.Parent.Children = append(node.Parent.Children, node)
}
}
return sfTreeMap
}
type SubforumLineageBuilder struct {
Tree SubforumTree
SubforumCache map[int][]*Subforum
SlugCache map[int][]string
}
func MakeSubforumLineageBuilder(fullSubforumTree SubforumTree) *SubforumLineageBuilder {
return &SubforumLineageBuilder{
Tree: fullSubforumTree,
SubforumCache: make(map[int][]*Subforum),
SlugCache: make(map[int][]string),
}
}
func (cl *SubforumLineageBuilder) GetLineage(sfId int) []*Subforum {
_, ok := cl.SubforumCache[sfId]
if !ok {
cl.SubforumCache[sfId] = cl.Tree[sfId].GetLineage()
}
return cl.SubforumCache[sfId]
}
func (cl *SubforumLineageBuilder) GetSubforumLineage(sfId int) []*Subforum {
return cl.GetLineage(sfId)[1:]
}
func (cl *SubforumLineageBuilder) GetLineageSlugs(sfId int) []string {
_, ok := cl.SlugCache[sfId]
if !ok {
lineage := cl.GetLineage(sfId)
result := make([]string, 0, len(lineage))
for _, cat := range lineage {
result = append(result, cat.Slug)
}
cl.SlugCache[sfId] = result
}
return cl.SlugCache[sfId]
}
func (cl *SubforumLineageBuilder) GetSubforumLineageSlugs(sfId int) []string {
return cl.GetLineageSlugs(sfId)[1:]
}
func (cl *SubforumLineageBuilder) FindIdBySlug(projectId int, slug string) int {
for _, node := range cl.Tree {
if node.Slug == slug && node.ProjectID == projectId {
return node.ID
}
}
return -1
}

View File

@ -1,9 +1,24 @@
package models
type ThreadType int
const (
ThreadTypeProjectArticle ThreadType = iota + 1
ThreadTypeForumPost
_ // formerly occupied by static pages, RIP
_ // formerly occupied by who the hell knows what, RIP
_ // formerly occupied by the wiki, RIP
_ // formerly occupied by library discussions, RIP
ThreadTypePersonalArticle
)
type Thread struct {
ID int `db:"id"`
CategoryID int `db:"category_id"`
Type ThreadType `db:"type"`
ProjectID int `db:"project_id"`
SubforumID *int `db:"subforum_id"`
PersonalArticleUserID *int `db:"personal_article_user_id"`
Title string `db:"title"`
Sticky bool `db:"sticky"`

View File

@ -1,3 +1,5 @@
// +build js
package main
import (

View File

@ -157,13 +157,6 @@
}
.post {
.wiki &,
{
padding: 0;
margin: auto;
max-width: 70em;
}
.contents {
h1, h2 {
margin: 20px 0px;
@ -332,79 +325,12 @@
}
}
.wiki {
.post p {
margin: 10px 0px;
}
.toc {
@include usevar(border-color, 'wiki-border-color');
border-left-width: 1px;
.toc-number {
@include usevar(color, 'wiki-toc-number-color');
}
ul {
list-style-type: none;
margin-left: 10px;
margin-bottom: 5px;
}
li {
margin-left: 0px;
}
}
.aside {
@include usevar(border-color, 'wiki-border-color');
border-left-width: 1px;
margin-left:20px;
&::before {
margin-left:-20px;
margin-right:15px;
display:inline-block;
width:10px;
content:"\21b4 ";
}
.aside-heading {
padding:2px;
margin:1px;
border-radius:3px;
border-width:0px;
cursor:pointer;
display:inline;
background-color:transparent;
}
> .aside-body {
overflow:hidden;
padding-left:10px;
}
&.folded {
&::before {
content:"\2192 ";
}
> .aside-body {
max-height: 0px;
}
}
}
}
.featured-post .meta .avatar-icon {
left:-60px;
bottom:-5px;
}
.blog .body blockquote,
.wiki .body blockquote {
.blog .body blockquote {
padding-top:1px;
padding-bottom:1px;
}

View File

@ -5,9 +5,6 @@
&.no-blogs .blogs {
display: none;
}
&.no-wiki .wiki {
display: none;
}
&.no-library .library {
display: none;
}

View File

@ -112,9 +112,6 @@ $vars: (
irc-users-popout-border-color-left: #444,
irc-users-popout-border-color-right: #333,
wiki-border-color: #444,
wiki-toc-number-color: #bbb,
code-line-number-color: #444,
library-star-btn-background: #252525,

View File

@ -112,9 +112,6 @@ $vars: (
irc-users-popout-border-color-left: #bbb,
irc-users-popout-border-color-right: #ccc,
wiki-border-color: #aaa,
wiki-toc-number-color: #333,
code-line-number-color: #777,
library-star-btn-background: #fff,

View File

@ -99,7 +99,6 @@ func ProjectToTemplate(p *models.Project, theme string) Project {
HasBlog: true, // TODO: Check flag sets or whatever
HasForum: true,
HasWiki: true,
HasLibrary: true,
DateApproved: p.DateApproved,

View File

@ -2,7 +2,7 @@
{{ define "content" }}
<div class="content-block">
{{ range .Subcategories }}
{{ range .Subforums }}
<div class="pv3">
<h2 class="ma0 ph3 pb2">
<a href="{{ .Url }}">
@ -21,18 +21,18 @@
</div>
{{ end }}
<div class="optionbar">
{{ template "forum_category_options" . }}
{{ template "subforum_options" . }}
</div>
{{ range .Threads }}
{{ template "thread_list_item.html" . }}
{{ end }}
<div class="optionbar bottom">
{{ template "forum_category_options" . }}
{{ template "subforum_options" . }}
</div>
</div>
{{ end }}
{{ define "forum_category_options" }}
{{ define "subforum_options" }}
<div class="options">
{{ if .User }}
<a class="button new-thread" href="{{ .NewThreadUrl }}"><span class="big pr1">+</span> New Thread</a>

View File

@ -4,7 +4,7 @@
<div class="content-block">
<div class="optionbar">
<div class="options">
<a class="button" href="{{ .CategoryUrl }}">&larr; Back to index</a>
<a class="button" href="{{ .SubforumUrl }}">&larr; Back to index</a>
</div>
<div class="options">
{{ template "pagination.html" .Pagination }}
@ -118,7 +118,7 @@
{{ end }}
<div class="optionbar bottom">
<div class="options order-1">
<a class="button" href="{{ .CategoryUrl }}">&larr; Back to index</a>
<a class="button" href="{{ .SubforumUrl }}">&larr; Back to index</a>
{{ if .Thread.Locked }}
<span>Thread is locked.</span>
{{ else if .User }}

View File

@ -51,9 +51,6 @@
{{ if .Project.HasForum }}
<a href="{{ .Header.ForumsUrl }}" class="forums">Forums</a>
{{ end }}
{{ if .Project.HasWiki }}
<a href="{{ .Header.WikiUrl }}" class="wiki">Wiki</a>
{{ end }}
{{ if .Project.HasLibrary }}
<a href="{{ .Header.LibraryUrl }}" class="library">Library</a>
{{ end }}

View File

@ -172,166 +172,6 @@
</div>
{{ end }}
{{/* TODO(asaf): Delete this section once we're done with the landing page
{% block columns %}
{% include "showcase/js_templates.html" %}
{% include "timeline/js_templates.html" %}
<div class="content-block pb3">
<div class="tc tl-l w-100 pb2">
<h2 class="di-l mr2-l">Community Showcase</h2>
<ul class="list dib-l">
<li class="dib-ns ma0 ph2">
<a href="{{ .ShowcaseUrl }}">View all</a>
</li>
</ul>
</div>
<div class="showcase relative overflow-hidden">
<div id="showcase-items" class="flex relative pl3 pl0-ns"></div>
<div class="arrow-container left">
<a href="javascript:void(0)" class="arrow svgicon svgicon-nofix" onclick="scrollShowcase('left')">{% svg 'chevron-left' %}</a>
</div>
<div class="arrow-container right">
<a href="javascript:void(0)" class="arrow svgicon svgicon-nofix" onclick="scrollShowcase('right')">{% svg 'chevron-right' %}</a>
</div>
</div>
<div class="c--dimmer i pv2 ph3 ph0-ns">
This is a selection of recent work done by community members. Want to participate? <a href="{{ .DiscordUrl }}" target="_blank">Join us on Discord.</a>
</div>
</div>
<script>
const timelineData = JSON.parse("{{ showcase_timeline_json|escapejs }}");
const showcaseEl = document.querySelector('#showcase-items');
for (const item of timelineData.items) {
const [itemEl, addThumbnail] = makeShowcaseItem(item);
addThumbnail();
itemEl.container.classList.add('mr3');
showcaseEl.appendChild(itemEl.root);
}
function rem2px(rem) {
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
}
function scrollShowcase(direction = null) {
const ITEM_WIDTH = showcaseEl.querySelector('.showcase-item').getBoundingClientRect().width;
const ITEM_SPACING = rem2px(1);
const showcaseWidth = showcaseEl.getBoundingClientRect().width;
const numVisible = showcaseWidth / (ITEM_WIDTH + ITEM_SPACING);
const scrollMagnitude = Math.floor(numVisible) - 1;
const scrollDirection = (direction === 'right' ? 1 : (direction === 'left' ? -1 : 0));
const scrollAmount = scrollMagnitude * scrollDirection;
const minIndex = 0;
const maxIndex = timelineData.items.length - Math.floor(numVisible);
const currentScrollIndex = parseInt(showcaseEl.getAttribute('data-scroll-index'), 10) || 0;
const newScrollIndex = Math.max(minIndex, Math.min(maxIndex, currentScrollIndex + scrollAmount));
showcaseEl.style.transform = `translateX(${-newScrollIndex * (ITEM_WIDTH + ITEM_SPACING)}px)`;
showcaseEl.setAttribute('data-scroll-index', newScrollIndex);
const leftArrowEl = document.querySelector('.arrow-container.left');
const rightArrowEl = document.querySelector('.arrow-container.right');
leftArrowEl.classList.toggle('hide', newScrollIndex === minIndex);
rightArrowEl.classList.toggle('hide', newScrollIndex === maxIndex);
}
scrollShowcase(); // force a scroll as an easy way to initialize styles
window.addEventListener('resize', () => scrollShowcase());
</script>
<div class="content-block">
<div class="optionbar pb2">
<div class="tc tl-l w-100">
<h2 class="di-l mr2-l">Around the Network</h2>
<ul class="list dib-l">
<li class="dib-ns ma0 ph2">
<a href="{% url 'feed' %}">View all posts on HMN</a>
</li>
<li class="dib-ns ma0 ph2">
<a href="{% url 'podcast' %}">Podcast</a>
</li>
<!-- <li class="dib-ns ma0 ph2">
<a href="{% url 'streams' %}">See who's live</a>
</li> -->
<li class="dib-ns ma0 ph2">
<a href="/blogs/p/1138-%5Btutorial%5D_handmade_network_irc" target="_blank">Chat in IRC</a>
</li>
<li class="dib-ns ma0 ph2">
<a href="https://discord.gg/hxWxDee" target="_blank">Chat on Discord</a>
</li>
<li class="dib-ns ma0 ph2">
<a href="https://handmadedev.show/" target="_blank">See the Show</a>
</li>
</ul>
</div>
</div>
</div>
{% spaceless %}
<div class="content-block news cf">
{% for col in recent_post_columns %}
<div class="fl w-100 w-50-l">
<div class="mw7 mw-none-l center-layout">
{% if forloop.counter == 1 %}
<div class="pt3">
{% include "blog_index_thread_list_entry.html" with post=featured_post align_top=True %}
</div>
{% endif %}
{% for entry in col %}
{% with proj=entry.project posts=entry.posts %}
<div class="pt3" id="p{{proj.slug}}">
<a
href="{% url 'cover_page' subdomain=proj.slug %}"
{% if user|get_theme == 'dark' %}
style="color:#{% rgb_accent proj.color_1 0.55 %};"
{% else %}
style="color:#{% rgb_accent proj.color_1 0.25 %};"
{% endif %}
>
<h2 class="ph3">{{ proj.name }}</h2>
</a>
{% if entry.featured and proj.slug != "hmn" %}
{% with post=entry.featured.0 has_read=entry.featured.1 %}
{% if post.category.kind == 5 and post.parent == None %}
{% include "thread_list_entry.html" with thread=post.thread %}
{% else %}
{% include "blog_index_thread_list_entry.html" with align_top=True %}
{% endif %}
{% endwith %}
{% endif %}
{% for post, has_read in posts %}
{% if forloop.counter0 < max_posts %}
{% include "thread_list_entry.html" with thread=post.thread %}
{% endif %}
{% endfor %}
{% with more=posts|length|add:-5|clamp_lower:0 %}
{% if more > 0 %}
<div class="ph3 thread unread more">
<a class="title"
href="{% url 'project_forum' subdomain=proj.slug %}"
>{{ more }} more recently &rarr;</a>
</div>
{% endif %}
{% endwith %}
</div>
{% endwith %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endspaceless %}
{% endblock %}
*/}}
{{ define "landing_page_featured_post" }}
{{/* Call this template with a LandingPageFeaturedPost. */}}
<div class="flex items-start ph3 pv2 {{ if .Unread }}unread{{ else }}read{{ end }}">

View File

@ -25,12 +25,6 @@
{{ if gt .NumBlogs 0 }}
<div class="dib filter blogs mr2"><input data-type="blogs" class="v-mid mr1" type="checkbox" id="timeline-checkbox-blogs" checked /><label class="v-mid" for="timeline-checkbox-blogs">Blogs (<span class="count">{{ .NumBlogs }}</span>)</label></div>
{{ end }}
{{ if gt .NumWiki 0 }}
<div class="dib filter wiki mr2"><input data-type="wiki" class="v-mid mr1" type="checkbox" id="timeline-checkbox-wiki" checked /><label class="v-mid" for="timeline-checkbox-wiki">Wiki (<span class="count">{{ .NumWiki }}</span>)</label></div>
{{ end }}
{{ if gt .NumLibrary 0 }}
<div class="dib filter library mr2"><input data-type="library" class="v-mid mr1" type="checkbox" id="timeline-checkbox-library" checked /><label class="v-mid" for="timeline-checkbox-library">Library (<span class="count">{{ .NumLibrary }}</span>)</label></div>
{{ end }}
{{ if gt .NumSnippets 0 }}
<div class="dib filter snippets mr2"><input data-type="snippets" class="v-mid mr1" type="checkbox" id="timeline-checkbox-snippets" checked /><label class="v-mid" for="timeline-checkbox-snippets">Snippets (<span class="count">{{ .NumSnippets }}</span>)</label></div>
{{ end }}
@ -61,7 +55,7 @@
<div class="pair flex flex-wrap">
<div class="key flex-auto mr1">Posts</div>
<div class="value">{{ add .NumForums .NumBlogs .NumWiki .NumLibrary }}</div>
<div class="value">{{ add .NumForums .NumBlogs }}</div>
</div>
{{ if .ProfileUser.Email }}

View File

@ -218,11 +218,7 @@ var HMNTemplateFuncs = template.FuncMap{
if item.Type == TimelineTypeForumThread ||
item.Type == TimelineTypeForumReply ||
item.Type == TimelineTypeBlogPost ||
item.Type == TimelineTypeBlogComment ||
item.Type == TimelineTypeWikiCreate ||
item.Type == TimelineTypeWikiEdit ||
item.Type == TimelineTypeWikiTalk ||
item.Type == TimelineTypeLibraryComment {
item.Type == TimelineTypeBlogComment {
return true
}

View File

@ -39,7 +39,6 @@ type Header struct {
ProjectIndexUrl string
BlogUrl string
ForumsUrl string
WikiUrl string
LibraryUrl string
ManifestoUrl string
EpisodeGuideUrl string
@ -109,7 +108,6 @@ type Project struct {
HasBlog bool
HasForum bool
HasWiki bool
HasLibrary bool
UUID string
@ -202,10 +200,6 @@ const (
PostTypeBlogComment
PostTypeForumThread
PostTypeForumReply
PostTypeWikiCreate
PostTypeWikiTalk
PostTypeWikiEdit
PostTypeLibraryComment
)
// Data from post_list_item.html
@ -253,12 +247,6 @@ const (
TimelineTypeBlogPost
TimelineTypeBlogComment
TimelineTypeWikiCreate
TimelineTypeWikiEdit
TimelineTypeWikiTalk
TimelineTypeLibraryComment
TimelineTypeSnippetImage
TimelineTypeSnippetVideo
TimelineTypeSnippetAudio

View File

@ -1,14 +0,0 @@
package website
import (
"git.handmade.network/hmn/hmn/src/models"
)
var CategoryKindDisplayNames = map[models.CategoryKind]string{
models.CatKindBlog: "Blog",
models.CatKindForum: "Forums",
models.CatKindStatic: "Static Page",
models.CatKindAnnotation: "Episode Guide",
models.CatKindWiki: "Wiki",
models.CatKindLibraryResource: "Library",
}

View File

@ -37,11 +37,11 @@ func Feed(c *RequestContext) ResponseData {
FROM
handmade_post AS post
WHERE
post.category_kind = ANY ($1)
post.thread_type = ANY ($1)
AND deleted = FALSE
AND post.thread_id IS NOT NULL
`,
[]models.CategoryKind{models.CatKindForum, models.CatKindBlog, models.CatKindWiki, models.CatKindLibraryResource},
[]models.ThreadType{models.ThreadTypeForumPost, models.ThreadTypeProjectArticle},
)
c.Perf.EndBlock()
if err != nil {
@ -80,9 +80,9 @@ func Feed(c *RequestContext) ResponseData {
currentUserId = &c.CurrentUser.ID
}
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock()
posts, err := fetchAllPosts(c, lineageBuilder, currentUserId, howManyPostsToSkip, postsPerPage)
@ -98,7 +98,7 @@ func Feed(c *RequestContext) ResponseData {
BaseData: baseData,
AtomFeedUrl: hmnurl.BuildAtomFeed(),
MarkAllReadUrl: hmnurl.BuildForumCategoryMarkRead(0),
MarkAllReadUrl: hmnurl.BuildForumMarkRead(0),
Posts: posts,
Pagination: pagination,
}, c.Perf)
@ -158,9 +158,9 @@ func AtomFeed(c *RequestContext) ResponseData {
feedData.AtomFeedUrl = hmnurl.BuildAtomFeed()
feedData.FeedUrl = hmnurl.BuildFeed()
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock()
posts, err := fetchAllPosts(c, lineageBuilder, nil, 0, itemsPerFeed)
@ -303,18 +303,16 @@ func AtomFeed(c *RequestContext) ResponseData {
return res
}
func fetchAllPosts(c *RequestContext, lineageBuilder *models.CategoryLineageBuilder, currentUserID *int, offset int, limit int) ([]templates.PostListItem, error) {
func fetchAllPosts(c *RequestContext, lineageBuilder *models.SubforumLineageBuilder, currentUserID *int, offset int, limit int) ([]templates.PostListItem, error) {
c.Perf.StartBlock("SQL", "Fetch posts")
type feedPostQuery struct {
Post models.Post `db:"post"`
PostVersion models.PostVersion `db:"version"`
Thread models.Thread `db:"thread"`
Cat models.Category `db:"cat"`
Proj models.Project `db:"proj"`
LibraryResource *models.LibraryResource `db:"lib_resource"`
User models.User `db:"auth_user"`
ThreadLastReadTime *time.Time `db:"tlri.lastread"`
CatLastReadTime *time.Time `db:"clri.lastread"`
Post models.Post `db:"post"`
PostVersion models.PostVersion `db:"version"`
Thread models.Thread `db:"thread"`
Proj models.Project `db:"proj"`
User models.User `db:"auth_user"`
ThreadLastReadTime *time.Time `db:"tlri.lastread"`
SubforumLastReadTime *time.Time `db:"slri.lastread"`
}
posts, err := db.Query(c.Context(), c.Conn, feedPostQuery{},
`
@ -323,27 +321,25 @@ func fetchAllPosts(c *RequestContext, lineageBuilder *models.CategoryLineageBuil
handmade_post AS post
JOIN handmade_postversion AS version ON version.id = post.current_id
JOIN handmade_thread AS thread ON thread.id = post.thread_id
JOIN handmade_category AS cat ON cat.id = post.category_id
JOIN handmade_project AS proj ON proj.id = post.project_id
LEFT JOIN handmade_threadlastreadinfo AS tlri ON (
tlri.thread_id = post.thread_id
AND tlri.user_id = $1
)
LEFT JOIN handmade_categorylastreadinfo AS clri ON (
clri.category_id = post.category_id
AND clri.user_id = $1
LEFT JOIN handmade_subforumlastreadinfo AS slri ON (
slri.subforum_id = thread.subforum_id
AND slri.user_id = $1
)
LEFT JOIN auth_user ON post.author_id = auth_user.id
LEFT JOIN handmade_libraryresource as lib_resource ON lib_resource.category_id = post.category_id
WHERE
post.category_kind = ANY ($2)
thread.type = ANY ($2)
AND post.deleted = FALSE
AND post.thread_id IS NOT NULL
ORDER BY postdate DESC
LIMIT $3 OFFSET $4
`,
currentUserID,
[]models.CategoryKind{models.CatKindForum, models.CatKindBlog, models.CatKindWiki, models.CatKindLibraryResource},
[]models.ThreadType{models.ThreadTypeForumPost, models.ThreadTypeProjectArticle},
limit,
offset,
)
@ -360,7 +356,7 @@ func fetchAllPosts(c *RequestContext, lineageBuilder *models.CategoryLineageBuil
hasRead := false
if postResult.ThreadLastReadTime != nil && postResult.ThreadLastReadTime.After(postResult.Post.PostDate) {
hasRead = true
} else if postResult.CatLastReadTime != nil && postResult.CatLastReadTime.After(postResult.Post.PostDate) {
} else if postResult.SubforumLastReadTime != nil && postResult.SubforumLastReadTime.After(postResult.Post.PostDate) {
hasRead = true
}
@ -370,7 +366,6 @@ func fetchAllPosts(c *RequestContext, lineageBuilder *models.CategoryLineageBuil
&postResult.Thread,
&postResult.Post,
&postResult.User,
postResult.LibraryResource,
!hasRead,
true,
c.Theme,

View File

@ -21,17 +21,17 @@ import (
"github.com/jackc/pgx/v4"
)
type forumCategoryData struct {
type forumData struct {
templates.BaseData
NewThreadUrl string
MarkReadUrl string
Threads []templates.ThreadListItem
Pagination templates.Pagination
Subcategories []forumSubcategoryData
NewThreadUrl string
MarkReadUrl string
Threads []templates.ThreadListItem
Pagination templates.Pagination
Subforums []forumSubforumData
}
type forumSubcategoryData struct {
type forumSubforumData struct {
Name string
Url string
Threads []templates.ThreadListItem
@ -50,7 +50,7 @@ type editorData struct {
PostReplyingTo *templates.Post
}
func ForumCategory(c *RequestContext) ResponseData {
func Forum(c *RequestContext) ResponseData {
const threadsPerPage = 25
cd, ok := getCommonForumData(c)
@ -58,7 +58,7 @@ func ForumCategory(c *RequestContext) ResponseData {
return FourOhFour(c)
}
currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID)
currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID)
c.Perf.StartBlock("SQL", "Fetch count of page threads")
numThreads, err := db.QueryInt(c.Context(), c.Conn,
@ -66,10 +66,10 @@ func ForumCategory(c *RequestContext) ResponseData {
SELECT COUNT(*)
FROM handmade_thread AS thread
WHERE
thread.category_id = $1
thread.subforum_id = $1
AND NOT thread.deleted
`,
cd.CatID,
cd.SubforumID,
)
if err != nil {
panic(oops.New(err, "failed to get count of threads"))
@ -84,11 +84,11 @@ func ForumCategory(c *RequestContext) ResponseData {
if pageParsed, err := strconv.Atoi(pageString); err == nil {
page = pageParsed
} else {
return c.Redirect(hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, 1), http.StatusSeeOther)
return c.Redirect(hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1), http.StatusSeeOther)
}
}
if page < 1 || numPages < page {
return c.Redirect(hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page, numPages)), http.StatusSeeOther)
return c.Redirect(hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page, numPages)), http.StatusSeeOther)
}
howManyThreadsToSkip := (page - 1) * threadsPerPage
@ -106,7 +106,7 @@ func ForumCategory(c *RequestContext) ResponseData {
FirstUser *models.User `db:"firstuser"`
LastUser *models.User `db:"lastuser"`
ThreadLastReadTime *time.Time `db:"tlri.lastread"`
CatLastReadTime *time.Time `db:"clri.lastread"`
ForumLastReadTime *time.Time `db:"slri.lastread"`
}
itMainThreads, err := db.Query(c.Context(), c.Conn, threadQueryResult{},
`
@ -121,17 +121,17 @@ func ForumCategory(c *RequestContext) ResponseData {
tlri.thread_id = thread.id
AND tlri.user_id = $2
)
LEFT JOIN handmade_categorylastreadinfo AS clri ON (
clri.category_id = $1
AND clri.user_id = $2
LEFT JOIN handmade_subforumlastreadinfo AS slri ON (
slri.subforum_id = $1
AND slri.user_id = $2
)
WHERE
thread.category_id = $1
thread.subforum_id = $1
AND NOT thread.deleted
ORDER BY lastpost.postdate DESC
LIMIT $3 OFFSET $4
`,
cd.CatID,
cd.SubforumID,
currentUserId,
threadsPerPage,
howManyThreadsToSkip,
@ -146,13 +146,13 @@ func ForumCategory(c *RequestContext) ResponseData {
hasRead := false
if row.ThreadLastReadTime != nil && row.ThreadLastReadTime.After(row.LastPost.PostDate) {
hasRead = true
} else if row.CatLastReadTime != nil && row.CatLastReadTime.After(row.LastPost.PostDate) {
} else if row.ForumLastReadTime != nil && row.ForumLastReadTime.After(row.LastPost.PostDate) {
hasRead = true
}
return templates.ThreadListItem{
Title: row.Thread.Title,
Url: hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(row.Thread.CategoryID), row.Thread.ID, row.Thread.Title, 1),
Url: hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(*row.Thread.SubforumID), row.Thread.ID, row.Thread.Title, 1),
FirstUser: templates.UserToTemplate(row.FirstUser, c.Theme),
FirstDate: row.FirstPost.PostDate,
LastUser: templates.UserToTemplate(row.LastUser, c.Theme),
@ -169,32 +169,32 @@ func ForumCategory(c *RequestContext) ResponseData {
}
// ---------------------
// Subcategory things
// Subforum things
// ---------------------
var subcats []forumSubcategoryData
var subforums []forumSubforumData
if page == 1 {
subcatNodes := cd.CategoryTree[cd.CatID].Children
subforumNodes := cd.SubforumTree[cd.SubforumID].Children
for _, catNode := range subcatNodes {
c.Perf.StartBlock("SQL", "Fetch count of subcategory threads")
// TODO(asaf): [PERF] [MINOR] Consider replacing querying count per subcat with a single query for all cats with GROUP BY.
for _, sfNode := range subforumNodes {
c.Perf.StartBlock("SQL", "Fetch count of subforum threads")
// TODO(asaf): [PERF] [MINOR] Consider replacing querying count per subforum with a single query for all subforums with GROUP BY.
numThreads, err := db.QueryInt(c.Context(), c.Conn,
`
SELECT COUNT(*)
FROM handmade_thread AS thread
WHERE
thread.category_id = $1
thread.subforum_id = $1
AND NOT thread.deleted
`,
catNode.ID,
sfNode.ID,
)
if err != nil {
panic(oops.New(err, "failed to get count of threads"))
}
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch subcategory threads")
c.Perf.StartBlock("SQL", "Fetch subforum threads")
// TODO(asaf): [PERF] [MINOR] Consider batching these.
itThreads, err := db.Query(c.Context(), c.Conn, threadQueryResult{},
`
@ -209,17 +209,17 @@ func ForumCategory(c *RequestContext) ResponseData {
tlri.thread_id = thread.id
AND tlri.user_id = $2
)
LEFT JOIN handmade_categorylastreadinfo AS clri ON (
clri.category_id = $1
AND clri.user_id = $2
LEFT JOIN handmade_subforumlastreadinfo AS slri ON (
slri.subforum_id = $1
AND slri.user_id = $2
)
WHERE
thread.category_id = $1
thread.subforum_id = $1
AND NOT thread.deleted
ORDER BY lastpost.postdate DESC
LIMIT 3
`,
catNode.ID,
sfNode.ID,
currentUserId,
)
if err != nil {
@ -234,9 +234,9 @@ func ForumCategory(c *RequestContext) ResponseData {
threads = append(threads, makeThreadListItem(threadRow))
}
subcats = append(subcats, forumSubcategoryData{
Name: *catNode.Name,
Url: hmnurl.BuildForumCategory(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(catNode.ID), 1),
subforums = append(subforums, forumSubforumData{
Name: sfNode.Name,
Url: hmnurl.BuildForum(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(sfNode.ID), 1),
Threads: threads,
TotalThreads: numThreads,
})
@ -249,53 +249,53 @@ func ForumCategory(c *RequestContext) ResponseData {
baseData := getBaseData(c)
baseData.Title = c.CurrentProject.Name + " Forums"
baseData.Breadcrumbs = []templates.Breadcrumb{ // TODO(ben): This is wrong; it needs to account for subcategories.
baseData.Breadcrumbs = []templates.Breadcrumb{ // TODO(ben): This is wrong; it needs to account for subforums.
{
Name: c.CurrentProject.Name,
Url: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
},
{
Name: "Forums",
Url: hmnurl.BuildForumCategory(c.CurrentProject.Slug, nil, 1),
Url: hmnurl.BuildForum(c.CurrentProject.Slug, nil, 1),
Current: true,
},
}
currentSubforums := cd.LineageBuilder.GetSubforumLineage(cd.CatID)
currentSubforums := cd.LineageBuilder.GetSubforumLineage(cd.SubforumID)
for i, subforum := range currentSubforums {
baseData.Breadcrumbs = append(baseData.Breadcrumbs, templates.Breadcrumb{
Name: *subforum.Name, // NOTE(asaf): All subforum categories must have names.
Url: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs[0:i+1], 1),
Name: subforum.Name,
Url: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs[0:i+1], 1),
})
}
var res ResponseData
res.MustWriteTemplate("forum_category.html", forumCategoryData{
res.MustWriteTemplate("forum.html", forumData{
BaseData: baseData,
NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs, false),
MarkReadUrl: hmnurl.BuildForumCategoryMarkRead(cd.CatID),
MarkReadUrl: hmnurl.BuildForumMarkRead(cd.SubforumID),
Threads: threads,
Pagination: templates.Pagination{
Current: page,
Total: numPages,
FirstUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, 1),
LastUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, numPages),
NextUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page+1, numPages)),
PreviousUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page-1, numPages)),
FirstUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1),
LastUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, numPages),
NextUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page+1, numPages)),
PreviousUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page-1, numPages)),
},
Subcategories: subcats,
Subforums: subforums,
}, c.Perf)
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)
func ForumMarkRead(c *RequestContext) ResponseData {
c.Perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock()
catId, err := strconv.Atoi(c.PathParams["catid"])
sfId, err := strconv.Atoi(c.PathParams["sfid"])
if err != nil {
return FourOhFour(c)
}
@ -307,28 +307,28 @@ func ForumCategoryMarkRead(c *RequestContext) ResponseData {
defer tx.Rollback(c.Context())
// TODO(ben): Rework this logic when we rework blogs, threads, etc.
catIds := []int{catId}
if catId == 0 {
sfIds := []int{sfId}
if sfId == 0 {
// Select all categories
type catIdResult struct {
CatID int `db:"id"`
type sfIdResult struct {
SubforumID int `db:"id"`
}
cats, err := db.Query(c.Context(), tx, catIdResult{},
cats, err := db.Query(c.Context(), tx, sfIdResult{},
`
SELECT $columns
FROM handmade_category
FROM handmade_subforum
WHERE kind = ANY ($1)
`,
[]models.CategoryKind{models.CatKindBlog, models.CatKindForum, models.CatKindWiki, models.CatKindLibraryResource},
[]models.ThreadType{models.ThreadTypeProjectArticle, models.ThreadTypeForumPost},
)
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))
sfIds = make([]int, len(catIdResults))
for i, res := range catIdResults {
catIds[i] = res.(*catIdResult).CatID
sfIds[i] = res.(*sfIdResult).SubforumID
}
}
@ -342,7 +342,7 @@ func ForumCategoryMarkRead(c *RequestContext) ResponseData {
ON CONFLICT (category_id, user_id) DO UPDATE
SET lastread = EXCLUDED.lastread
`,
catIds,
sfIds,
c.CurrentUser.ID,
time.Now(),
)
@ -364,7 +364,7 @@ func ForumCategoryMarkRead(c *RequestContext) ResponseData {
category_id = ANY ($1)
)
`,
catIds,
sfIds,
c.CurrentUser.ID,
)
c.Perf.EndBlock()
@ -378,10 +378,10 @@ func ForumCategoryMarkRead(c *RequestContext) ResponseData {
}
var redirUrl string
if catId == 0 {
if sfId == 0 {
redirUrl = hmnurl.BuildFeed()
} else {
redirUrl = hmnurl.BuildForumCategory(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(catId), 1)
redirUrl = hmnurl.BuildForum(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(sfId), 1)
}
return c.Redirect(redirUrl, http.StatusSeeOther)
}
@ -392,7 +392,7 @@ type forumThreadData struct {
Thread templates.Thread
Posts []templates.Post
CategoryUrl string
SubforumUrl string
ReplyUrl string
Pagination templates.Pagination
}
@ -405,7 +405,7 @@ func ForumThread(c *RequestContext) ResponseData {
return FourOhFour(c)
}
currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID)
currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID)
thread := cd.FetchThread(c.Context(), c.Conn)
@ -519,7 +519,7 @@ func ForumThread(c *RequestContext) ResponseData {
BaseData: baseData,
Thread: templates.ThreadToTemplate(thread),
Posts: posts,
CategoryUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, 1),
SubforumUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1),
ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, *thread.FirstID),
Pagination: pagination,
}, c.Perf)
@ -542,12 +542,10 @@ func ForumPostRedirect(c *RequestContext) ResponseData {
FROM
handmade_post AS post
WHERE
post.category_id = $1
AND post.thread_id = $2
post.thread_id = $1
AND NOT post.deleted
ORDER BY postdate
`,
cd.CatID,
cd.ThreadID,
)
if err != nil {
@ -588,7 +586,7 @@ func ForumPostRedirect(c *RequestContext) ResponseData {
return c.Redirect(hmnurl.BuildForumThreadWithPostHash(
c.CurrentProject.Slug,
cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID),
cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID),
cd.ThreadID,
threadTitle,
page,
@ -610,7 +608,7 @@ func ForumNewThread(c *RequestContext) ResponseData {
var res ResponseData
res.MustWriteTemplate("editor.html", editorData{
BaseData: baseData,
SubmitUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), true),
SubmitUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), true),
SubmitLabel: "Post New Thread",
}, c.Perf)
return res
@ -643,13 +641,15 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
var threadId int
err = tx.QueryRow(c.Context(),
`
INSERT INTO handmade_thread (title, sticky, category_id, first_id, last_id)
VALUES ($1, $2, $3, $4, $5)
INSERT INTO handmade_thread (title, sticky, type, project_id, subforum_id, first_id, last_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`,
title,
sticky,
cd.CatID,
models.ThreadTypeForumPost,
c.CurrentProject.ID,
cd.SubforumID,
-1,
-1,
).Scan(&threadId)
@ -657,7 +657,7 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
panic(oops.New(err, "failed to create thread"))
}
postId, _ := createNewForumPostAndVersion(c.Context(), tx, cd.CatID, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, nil)
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(),
@ -680,7 +680,7 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new forum thread"))
}
newThreadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), threadId, title, 1)
newThreadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), threadId, title, 1)
return c.Redirect(newThreadUrl, http.StatusSeeOther)
}
@ -693,7 +693,7 @@ func ForumPostReply(c *RequestContext) ResponseData {
postData := cd.FetchPostAndStuff(c.Context(), c.Conn)
baseData := getBaseData(c)
baseData.Title = fmt.Sprintf("Replying to \"%s\" | %s", postData.Thread.Title, *cd.CategoryTree[cd.CatID].Name)
baseData.Title = fmt.Sprintf("Replying to \"%s\" | %s", postData.Thread.Title, cd.SubforumTree[cd.SubforumID].Name)
baseData.MathjaxEnabled = true
// TODO(ben): Set breadcrumbs
@ -703,7 +703,7 @@ func ForumPostReply(c *RequestContext) ResponseData {
var res ResponseData
res.MustWriteTemplate("editor.html", editorData{
BaseData: baseData,
SubmitUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.ThreadID, cd.PostID),
SubmitUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID),
SubmitLabel: "Submit Reply",
Title: "Replying to post",
@ -729,14 +729,14 @@ func ForumPostReplySubmit(c *RequestContext) ResponseData {
unparsed := c.Req.Form.Get("body")
newPostId, _ := createNewForumPostAndVersion(c.Context(), tx, cd.CatID, cd.ThreadID, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, &cd.PostID)
newPostId, _ := createNewForumPostAndVersion(c.Context(), tx, cd.ThreadID, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, &cd.PostID)
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to forum post"))
}
newPostUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.ThreadID, newPostId)
newPostUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, newPostId)
return c.Redirect(newPostUrl, http.StatusSeeOther)
}
@ -753,7 +753,7 @@ func ForumPostEdit(c *RequestContext) ResponseData {
postData := cd.FetchPostAndStuff(c.Context(), c.Conn)
baseData := getBaseData(c)
baseData.Title = fmt.Sprintf("Editing \"%s\" | %s", postData.Thread.Title, *cd.CategoryTree[cd.CatID].Name)
baseData.Title = fmt.Sprintf("Editing \"%s\" | %s", postData.Thread.Title, cd.SubforumTree[cd.SubforumID].Name)
baseData.MathjaxEnabled = true
// TODO(ben): Set breadcrumbs
@ -763,7 +763,7 @@ func ForumPostEdit(c *RequestContext) ResponseData {
var res ResponseData
res.MustWriteTemplate("editor.html", editorData{
BaseData: baseData,
SubmitUrl: hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.ThreadID, cd.PostID),
SubmitUrl: hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID),
Title: postData.Thread.Title,
SubmitLabel: "Submit Edited Post",
@ -801,7 +801,7 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit forum post"))
}
postUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.ThreadID, cd.PostID)
postUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
return c.Redirect(postUrl, http.StatusSeeOther)
}
@ -818,7 +818,7 @@ func ForumPostDelete(c *RequestContext) ResponseData {
postData := cd.FetchPostAndStuff(c.Context(), c.Conn)
baseData := getBaseData(c)
baseData.Title = fmt.Sprintf("Deleting post in \"%s\" | %s", postData.Thread.Title, *cd.CategoryTree[cd.CatID].Name)
baseData.Title = fmt.Sprintf("Deleting post in \"%s\" | %s", postData.Thread.Title, cd.SubforumTree[cd.SubforumID].Name)
baseData.MathjaxEnabled = true
// TODO(ben): Set breadcrumbs
@ -834,7 +834,7 @@ func ForumPostDelete(c *RequestContext) ResponseData {
var res ResponseData
res.MustWriteTemplate("forum_post_delete.html", forumPostDeleteData{
BaseData: baseData,
SubmitUrl: hmnurl.BuildForumPostDelete(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.ThreadID, cd.PostID),
SubmitUrl: hmnurl.BuildForumPostDelete(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID),
Post: templatePost,
}, c.Perf)
return res
@ -895,7 +895,7 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete thread and posts when deleting the first post"))
}
forumUrl := hmnurl.BuildForumCategory(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), 1)
forumUrl := hmnurl.BuildForum(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), 1)
return c.Redirect(forumUrl, http.StatusSeeOther)
}
@ -926,24 +926,23 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete post"))
}
threadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.ThreadID, "", 1) // TODO: Go to the last page of the thread? Or the post before the post we just deleted?
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, catId, threadId, userId, projectId int, unparsedContent string, ipString string, replyId *int) (postId, versionId int) {
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, category_id, thread_id, current_id, author_id, category_kind, project_id, reply_id, preview)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
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(),
catId,
threadId,
models.ThreadTypeForumPost,
-1,
userId,
models.CatKindForum,
projectId,
replyId,
"", // empty preview, will be updated later
@ -1064,16 +1063,16 @@ func fixThreadPostIds(ctx context.Context, tx pgx.Tx, threadId int) error {
type commonForumData struct {
c *RequestContext
CatID int
ThreadID int
PostID int
SubforumID int
ThreadID int
PostID int
CategoryTree models.CategoryTree
LineageBuilder *models.CategoryLineageBuilder
SubforumTree models.SubforumTree
LineageBuilder *models.SubforumLineageBuilder
}
/*
Gets data that is used on basically every forums-related route. Parses path params for category,
Gets data that is used on basically every forums-related route. Parses path params for subforum,
thread, and post ids and validates that all those resources do in fact exist.
Returns false if any data is invalid and you should return a 404.
@ -1082,25 +1081,25 @@ func getCommonForumData(c *RequestContext) (commonForumData, bool) {
c.Perf.StartBlock("FORUMS", "Fetch common forum data")
defer c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock()
res := commonForumData{
c: c,
CategoryTree: categoryTree,
SubforumTree: subforumTree,
LineageBuilder: lineageBuilder,
}
if cats, hasCats := c.PathParams["cats"]; hasCats {
catId, valid := validateSubforums(lineageBuilder, c.CurrentProject, cats)
if subforums, hasSubforums := c.PathParams["subforums"]; hasSubforums {
sfId, valid := validateSubforums(lineageBuilder, c.CurrentProject, subforums)
if !valid {
return commonForumData{}, false
}
res.CatID = catId
res.SubforumID = sfId
// No need to validate cat here; it's handled by validateSubforums.
// No need to validate that subforum exists here; it's handled by validateSubforums.
}
if threadIdStr, hasThreadId := c.PathParams["threadid"]; hasThreadId {
@ -1117,10 +1116,10 @@ func getCommonForumData(c *RequestContext) (commonForumData, bool) {
FROM handmade_thread
WHERE
id = $1
AND category_id = $2
AND subforum_id = $2
`,
res.ThreadID,
res.CatID,
res.SubforumID,
)
c.Perf.EndBlock()
if err != nil {
@ -1146,11 +1145,9 @@ func getCommonForumData(c *RequestContext) (commonForumData, bool) {
WHERE
id = $1
AND thread_id = $2
AND category_id = $3
`,
res.PostID,
res.ThreadID,
res.CatID,
)
c.Perf.EndBlock()
if err != nil {
@ -1180,14 +1177,14 @@ func (cd *commonForumData) FetchThread(ctx context.Context, connOrTx db.ConnOrTx
SELECT $columns
FROM
handmade_thread AS thread
JOIN handmade_category AS cat ON cat.id = thread.category_id
JOIN handmade_subforum AS sf ON sf.id = thread.subforum_id
WHERE
thread.id = $1
AND NOT thread.deleted
AND cat.id = $2
AND sf.id = $2
`,
cd.ThreadID,
cd.CatID, // NOTE(asaf): This verifies that the requested thread is under the requested subforum.
cd.SubforumID, // NOTE(asaf): This verifies that the requested thread is under the requested subforum.
)
cd.c.Perf.EndBlock()
if err != nil {
@ -1225,12 +1222,10 @@ func (cd *commonForumData) FetchPostAndStuff(ctx context.Context, connOrTx db.Co
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
post.thread_id = $1
AND post.id = $2
AND NOT post.deleted
`,
cd.CatID,
cd.ThreadID,
cd.PostID,
)
@ -1274,27 +1269,27 @@ func (cd *commonForumData) UserCanEditPost(ctx context.Context, connOrTx db.Conn
return result.AuthorID != nil && *result.AuthorID == user.ID
}
func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, catPath string) (int, bool) {
func validateSubforums(lineageBuilder *models.SubforumLineageBuilder, project *models.Project, sfPath string) (int, bool) {
if project.ForumID == nil {
return -1, false
}
subforumCatId := *project.ForumID
if len(catPath) == 0 {
return subforumCatId, true
subforumId := *project.ForumID
if len(sfPath) == 0 {
return subforumId, true
}
catPath = strings.ToLower(catPath)
sfPath = strings.ToLower(sfPath)
valid := false
catSlugs := strings.Split(catPath, "/")
lastSlug := catSlugs[len(catSlugs)-1]
sfSlugs := strings.Split(sfPath, "/")
lastSlug := sfSlugs[len(sfSlugs)-1]
if len(lastSlug) > 0 {
lastSlugCatId := lineageBuilder.FindIdBySlug(project.ID, lastSlug)
if lastSlugCatId != -1 {
subforumSlugs := lineageBuilder.GetSubforumLineageSlugs(lastSlugCatId)
lastSlugSfId := lineageBuilder.FindIdBySlug(project.ID, lastSlug)
if lastSlugSfId != -1 {
subforumSlugs := lineageBuilder.GetSubforumLineageSlugs(lastSlugSfId)
allMatch := true
for i, subforum := range subforumSlugs {
if subforum != catSlugs[i] {
if subforum != sfSlugs[i] {
allMatch = false
break
}
@ -1302,8 +1297,8 @@ func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *m
valid = allMatch
}
if valid {
subforumCatId = lastSlugCatId
subforumId = lastSlugSfId
}
}
return subforumCatId, valid
return subforumId, valid
}

View File

@ -74,9 +74,9 @@ func Index(c *RequestContext) ResponseData {
allProjects := iterProjects.ToSlice()
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock()
var currentUserId *int
@ -90,12 +90,11 @@ func Index(c *RequestContext) ResponseData {
c.Perf.StartBlock("SQL", fmt.Sprintf("Fetch posts for %s", proj.Name))
type projectPostQuery struct {
Post models.Post `db:"post"`
Thread models.Thread `db:"thread"`
User models.User `db:"auth_user"`
LibraryResource *models.LibraryResource `db:"lib_resource"`
ThreadLastReadTime *time.Time `db:"tlri.lastread"`
CatLastReadTime *time.Time `db:"clri.lastread"`
Post models.Post `db:"post"`
Thread models.Thread `db:"thread"`
User models.User `db:"auth_user"`
ThreadLastReadTime *time.Time `db:"tlri.lastread"`
SubforumLastReadTime *time.Time `db:"slri.lastread"`
}
projectPostIter, err := db.Query(c.Context(), c.Conn, projectPostQuery{},
`
@ -107,22 +106,21 @@ func Index(c *RequestContext) ResponseData {
tlri.thread_id = post.thread_id
AND tlri.user_id = $1
)
LEFT JOIN handmade_categorylastreadinfo AS clri ON (
clri.category_id = post.category_id
AND clri.user_id = $1
LEFT JOIN handmade_subforumlastreadinfo AS slri ON (
slri.subforum_id = thread.subforum_id
AND slri.user_id = $1
)
LEFT JOIN auth_user ON post.author_id = auth_user.id
LEFT JOIN handmade_libraryresource as lib_resource ON lib_resource.category_id = post.category_id
WHERE
post.project_id = $2
AND post.category_kind IN ($3, $4, $5, $6)
AND post.thread_type = ANY ($3)
AND post.deleted = FALSE
ORDER BY postdate DESC
LIMIT $7
LIMIT $4
`,
currentUserId,
proj.ID,
models.CatKindBlog, models.CatKindForum, models.CatKindWiki, models.CatKindLibraryResource,
[]models.ThreadType{models.ThreadTypeProjectArticle, models.ThreadTypeForumPost},
maxPosts,
)
c.Perf.EndBlock()
@ -134,7 +132,7 @@ func Index(c *RequestContext) ResponseData {
forumsUrl := ""
if proj.ForumID != nil {
forumsUrl = hmnurl.BuildForumCategory(proj.Slug, lineageBuilder.GetSubforumLineageSlugs(*proj.ForumID), 1)
forumsUrl = hmnurl.BuildForum(proj.Slug, lineageBuilder.GetSubforumLineageSlugs(*proj.ForumID), 1)
} else {
c.Logger.Error().Int("ProjectID", proj.ID).Str("ProjectName", proj.Name).Msg("Project fetched by landing page but it doesn't have forums")
}
@ -150,12 +148,12 @@ func Index(c *RequestContext) ResponseData {
hasRead := false
if projectPost.ThreadLastReadTime != nil && projectPost.ThreadLastReadTime.After(projectPost.Post.PostDate) {
hasRead = true
} else if projectPost.CatLastReadTime != nil && projectPost.CatLastReadTime.After(projectPost.Post.PostDate) {
} else if projectPost.SubforumLastReadTime != nil && projectPost.SubforumLastReadTime.After(projectPost.Post.PostDate) {
hasRead = true
}
featurable := (!proj.IsHMN() &&
projectPost.Post.CategoryKind == models.CatKindBlog &&
projectPost.Post.ThreadType == models.ThreadTypeProjectArticle &&
projectPost.Post.ParentID == nil &&
landingPageProject.FeaturedPost == nil)
@ -197,7 +195,6 @@ func Index(c *RequestContext) ResponseData {
&projectPost.Thread,
&projectPost.Post,
&projectPost.User,
projectPost.LibraryResource,
!hasRead,
false,
c.Theme,
@ -263,14 +260,14 @@ func Index(c *RequestContext) ResponseData {
JOIN handmade_postversion AS ver ON post.current_id = ver.id
WHERE
post.project_id = $1
AND post.category_kind = $2
AND thread.type = $2
AND post.id = thread.first_id
AND NOT thread.deleted
ORDER BY post.postdate DESC
LIMIT 1
`,
models.HMNProjectID,
models.CatKindBlog,
models.ThreadTypeProjectArticle,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch news post"))
@ -294,6 +291,7 @@ func Index(c *RequestContext) ResponseData {
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
ORDER BY snippet.when DESC
LIMIT 20
`,
)
if err != nil {

View File

@ -7,92 +7,74 @@ import (
)
// NOTE(asaf): Please don't use this if you already know the kind of the post beforehand. Just call the appropriate build function.
// You may pass 0 for `libraryResourceId` if the post is not a library resource post.
func UrlForGenericPost(post *models.Post, subforums []string, threadTitle string, libraryResourceId int, projectSlug string) string {
switch post.CategoryKind {
case models.CatKindBlog:
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)
case models.CatKindForum:
return hmnurl.BuildForumPost(projectSlug, subforums, post.ThreadID, post.ID)
case models.CatKindWiki:
if post.ParentID == nil {
// NOTE(asaf): First post on a wiki "thread" is the wiki article itself
return hmnurl.BuildWikiArticle(projectSlug, post.ThreadID, threadTitle)
} else {
// NOTE(asaf): Subsequent posts on a wiki "thread" are wiki talk posts
return hmnurl.BuildWikiTalkPost(projectSlug, post.ThreadID, post.ID)
}
case models.CatKindLibraryResource:
return hmnurl.BuildLibraryPost(projectSlug, libraryResourceId, post.ThreadID, post.ID)
case models.ThreadTypeForumPost:
return hmnurl.BuildForumPost(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), post.ThreadID, post.ID)
}
return hmnurl.BuildProjectHomepage(projectSlug)
}
var PostTypeMap = map[models.CategoryKind][]templates.PostType{
models.CatKindBlog: []templates.PostType{templates.PostTypeBlogPost, templates.PostTypeBlogComment},
models.CatKindForum: []templates.PostType{templates.PostTypeForumThread, templates.PostTypeForumReply},
models.CatKindWiki: []templates.PostType{templates.PostTypeWikiCreate, templates.PostTypeWikiTalk},
models.CatKindLibraryResource: []templates.PostType{templates.PostTypeLibraryComment, templates.PostTypeLibraryComment},
var PostTypeMap = map[models.ThreadType][]templates.PostType{
models.ThreadTypeProjectArticle: {templates.PostTypeBlogPost, templates.PostTypeBlogComment},
models.ThreadTypeForumPost: {templates.PostTypeForumThread, templates.PostTypeForumReply},
}
var PostTypePrefix = map[templates.PostType]string{
templates.PostTypeBlogPost: "New blog post",
templates.PostTypeBlogComment: "Blog comment",
templates.PostTypeForumThread: "New forum thread",
templates.PostTypeForumReply: "Forum reply",
templates.PostTypeWikiCreate: "New wiki page",
templates.PostTypeWikiTalk: "Wiki comment",
templates.PostTypeWikiEdit: "Wiki edit",
templates.PostTypeLibraryComment: "Library comment",
templates.PostTypeBlogPost: "New blog post",
templates.PostTypeBlogComment: "Blog comment",
templates.PostTypeForumThread: "New forum thread",
templates.PostTypeForumReply: "Forum reply",
}
func PostBreadcrumbs(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, post *models.Post, libraryResource *models.LibraryResource) []templates.Breadcrumb {
func PostBreadcrumbs(lineageBuilder *models.SubforumLineageBuilder, project *models.Project, thread *models.Thread) []templates.Breadcrumb {
var result []templates.Breadcrumb
result = append(result, templates.Breadcrumb{
Name: project.Name,
Url: hmnurl.BuildProjectHomepage(project.Slug),
})
result = append(result, templates.Breadcrumb{
Name: CategoryKindDisplayNames[post.CategoryKind],
Url: BuildProjectMainCategoryUrl(project.Slug, post.CategoryKind),
Name: ThreadTypeDisplayNames[thread.Type],
Url: BuildProjectRootResourceUrl(project.Slug, thread.Type),
})
switch post.CategoryKind {
case models.CatKindForum:
subforums := lineageBuilder.GetSubforumLineage(post.CategoryID)
slugs := lineageBuilder.GetSubforumLineageSlugs(post.CategoryID)
switch thread.Type {
case models.ThreadTypeForumPost:
subforums := lineageBuilder.GetSubforumLineage(*thread.SubforumID)
slugs := lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID)
for i, subforum := range subforums {
result = append(result, templates.Breadcrumb{
Name: *subforum.Name, // NOTE(asaf): All subforum categories must have names.
Url: hmnurl.BuildForumCategory(project.Slug, slugs[0:i+1], 1),
Name: subforum.Name,
Url: hmnurl.BuildForum(project.Slug, slugs[0:i+1], 1),
})
}
case models.CatKindLibraryResource:
result = append(result, templates.Breadcrumb{
Name: libraryResource.Name,
Url: hmnurl.BuildLibraryResource(project.Slug, libraryResource.ID),
})
}
return result
}
// NOTE(asaf): THIS DOESN'T HANDLE WIKI EDIT ITEMS. Wiki edits are PostTextVersions, not Posts.
func MakePostListItem(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, thread *models.Thread, post *models.Post, user *models.User, libraryResource *models.LibraryResource, unread bool, includeBreadcrumbs bool, currentTheme string) templates.PostListItem {
func MakePostListItem(
lineageBuilder *models.SubforumLineageBuilder,
project *models.Project,
thread *models.Thread,
post *models.Post,
user *models.User,
unread bool,
includeBreadcrumbs bool,
currentTheme string,
) templates.PostListItem {
var result templates.PostListItem
result.Title = thread.Title
result.User = templates.UserToTemplate(user, currentTheme)
result.Date = post.PostDate
result.Unread = unread
libraryResourceId := 0
if libraryResource != nil {
libraryResourceId = libraryResource.ID
}
result.Url = UrlForGenericPost(post, lineageBuilder.GetSubforumLineageSlugs(post.CategoryID), thread.Title, libraryResourceId, project.Slug)
result.Url = UrlForGenericPost(thread, post, lineageBuilder, project.Slug)
result.Preview = post.Preview
postType := templates.PostTypeUnknown
postTypeOptions, found := PostTypeMap[post.CategoryKind]
postTypeOptions, found := PostTypeMap[post.ThreadType]
if found {
var hasParent int
if post.ParentID != nil {
@ -104,7 +86,7 @@ func MakePostListItem(lineageBuilder *models.CategoryLineageBuilder, project *mo
result.PostTypePrefix = PostTypePrefix[result.PostType]
if includeBreadcrumbs {
result.Breadcrumbs = PostBreadcrumbs(lineageBuilder, project, post, libraryResource)
result.Breadcrumbs = PostBreadcrumbs(lineageBuilder, project, thread)
}
return result

View File

@ -339,9 +339,9 @@ func ProjectHomepage(c *RequestContext) ResponseData {
}
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetching project timeline")
@ -431,7 +431,6 @@ func ProjectHomepage(c *RequestContext) ResponseData {
&post.(*postQuery).Post,
&post.(*postQuery).Thread,
project,
nil,
&post.(*postQuery).Author,
c.Theme,
))

View File

@ -156,8 +156,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
mainRoutes.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
mainRoutes.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit)))
mainRoutes.GET(hmnurl.RegexForumThread, ForumThread)
mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory)
mainRoutes.POST(hmnurl.RegexForumCategoryMarkRead, authMiddleware(csrfMiddleware(ForumCategoryMarkRead)))
mainRoutes.GET(hmnurl.RegexForum, Forum)
mainRoutes.POST(hmnurl.RegexForumMarkRead, authMiddleware(csrfMiddleware(ForumMarkRead)))
mainRoutes.GET(hmnurl.RegexForumPost, ForumPostRedirect)
mainRoutes.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply))
mainRoutes.POST(hmnurl.RegexForumPostReply, authMiddleware(csrfMiddleware(ForumPostReplySubmit)))
@ -216,8 +216,7 @@ func getBaseData(c *RequestContext) templates.BaseData {
ProjectHomepageUrl: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
ProjectIndexUrl: hmnurl.BuildProjectIndex(1),
BlogUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, 1),
ForumsUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, nil, 1),
WikiUrl: hmnurl.BuildWiki(c.CurrentProject.Slug),
ForumsUrl: hmnurl.BuildForum(c.CurrentProject.Slug, nil, 1),
LibraryUrl: hmnurl.BuildLibrary(c.CurrentProject.Slug),
ManifestoUrl: hmnurl.BuildManifesto(),
EpisodeGuideUrl: hmnurl.BuildHomepage(), // TODO(asaf)
@ -231,7 +230,7 @@ func getBaseData(c *RequestContext) templates.BaseData {
CodeOfConductUrl: hmnurl.BuildCodeOfConduct(),
CommunicationGuidelinesUrl: hmnurl.BuildCommunicationGuidelines(),
ProjectIndexUrl: hmnurl.BuildProjectIndex(1),
ForumsUrl: hmnurl.BuildForumCategory(models.HMNProjectSlug, nil, 1),
ForumsUrl: hmnurl.BuildForum(models.HMNProjectSlug, nil, 1),
ContactUrl: hmnurl.BuildContactPage(),
SitemapUrl: hmnurl.BuildSiteMap(),
},

View File

@ -0,0 +1,10 @@
package website
import (
"git.handmade.network/hmn/hmn/src/models"
)
var ThreadTypeDisplayNames = map[models.ThreadType]string{
models.ThreadTypeProjectArticle: "Blog",
models.ThreadTypeForumPost: "Forums",
}

View File

@ -10,12 +10,10 @@ import (
"git.handmade.network/hmn/hmn/src/templates"
)
var TimelineTypeMap = map[models.CategoryKind][]templates.TimelineType{
// { No parent , Has parent }
models.CatKindBlog: {templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment},
models.CatKindForum: {templates.TimelineTypeForumThread, templates.TimelineTypeForumReply},
models.CatKindWiki: {templates.TimelineTypeWikiCreate, templates.TimelineTypeWikiTalk},
models.CatKindLibraryResource: {templates.TimelineTypeLibraryComment, templates.TimelineTypeLibraryComment},
var TimelineTypeMap = map[models.ThreadType][]templates.TimelineType{
// { No parent , Has parent }
models.ThreadTypeProjectArticle: {templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment},
models.ThreadTypeForumPost: {templates.TimelineTypeForumThread, templates.TimelineTypeForumReply},
}
var TimelineItemClassMap = map[templates.TimelineType]string{
@ -27,12 +25,6 @@ var TimelineItemClassMap = map[templates.TimelineType]string{
templates.TimelineTypeBlogPost: "blogs",
templates.TimelineTypeBlogComment: "blogs",
templates.TimelineTypeWikiCreate: "wiki",
templates.TimelineTypeWikiEdit: "wiki",
templates.TimelineTypeWikiTalk: "wiki",
templates.TimelineTypeLibraryComment: "library",
templates.TimelineTypeSnippetImage: "snippets",
templates.TimelineTypeSnippetVideo: "snippets",
templates.TimelineTypeSnippetAudio: "snippets",
@ -48,21 +40,15 @@ var TimelineTypeTitleMap = map[templates.TimelineType]string{
templates.TimelineTypeBlogPost: "New blog post",
templates.TimelineTypeBlogComment: "Blog comment",
templates.TimelineTypeWikiCreate: "New wiki article",
templates.TimelineTypeWikiEdit: "Wiki edit",
templates.TimelineTypeWikiTalk: "Wiki talk",
templates.TimelineTypeLibraryComment: "Library comment",
templates.TimelineTypeSnippetImage: "Snippet",
templates.TimelineTypeSnippetVideo: "Snippet",
templates.TimelineTypeSnippetAudio: "Snippet",
templates.TimelineTypeSnippetYoutube: "Snippet",
}
func PostToTimelineItem(lineageBuilder *models.CategoryLineageBuilder, post *models.Post, thread *models.Thread, project *models.Project, libraryResource *models.LibraryResource, owner *models.User, currentTheme string) templates.TimelineItem {
func PostToTimelineItem(lineageBuilder *models.SubforumLineageBuilder, post *models.Post, thread *models.Thread, project *models.Project, owner *models.User, currentTheme string) templates.TimelineItem {
itemType := templates.TimelineTypeUnknown
typeByCatKind, found := TimelineTypeMap[post.CategoryKind]
typeByCatKind, found := TimelineTypeMap[post.ThreadType]
if found {
hasParent := 0
if post.ParentID != nil {
@ -71,17 +57,12 @@ func PostToTimelineItem(lineageBuilder *models.CategoryLineageBuilder, post *mod
itemType = typeByCatKind[hasParent]
}
libraryResourceId := 0
if libraryResource != nil {
libraryResourceId = libraryResource.ID
}
return templates.TimelineItem{
Type: itemType,
TypeTitle: TimelineTypeTitleMap[itemType],
Class: TimelineItemClassMap[itemType],
Date: post.PostDate,
Url: UrlForGenericPost(post, lineageBuilder.GetSubforumLineageSlugs(post.CategoryID), thread.Title, libraryResourceId, project.Slug),
Url: UrlForGenericPost(thread, post, lineageBuilder, project.Slug),
OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
OwnerName: templates.UserDisplayName(owner),
@ -89,25 +70,7 @@ func PostToTimelineItem(lineageBuilder *models.CategoryLineageBuilder, post *mod
Description: "", // NOTE(asaf): No description for posts
Title: thread.Title,
Breadcrumbs: PostBreadcrumbs(lineageBuilder, project, post, libraryResource),
}
}
func PostVersionToWikiTimelineItem(lineageBuilder *models.CategoryLineageBuilder, version *models.PostVersion, post *models.Post, thread *models.Thread, project *models.Project, owner *models.User, currentTheme string) templates.TimelineItem {
return templates.TimelineItem{
Type: templates.TimelineTypeWikiEdit,
TypeTitle: TimelineTypeTitleMap[templates.TimelineTypeWikiEdit],
Class: TimelineItemClassMap[templates.TimelineTypeWikiEdit],
Date: version.Date,
Url: hmnurl.BuildWikiArticle(project.Slug, thread.ID, thread.Title),
OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
OwnerName: templates.UserDisplayName(owner),
OwnerUrl: hmnurl.BuildUserProfile(owner.Username),
Description: "", // NOTE(asaf): No description for posts
Title: thread.Title,
Breadcrumbs: PostBreadcrumbs(lineageBuilder, project, post, nil),
Breadcrumbs: PostBreadcrumbs(lineageBuilder, project, thread),
}
}

View File

@ -5,16 +5,12 @@ import (
"git.handmade.network/hmn/hmn/src/models"
)
func BuildProjectMainCategoryUrl(projectSlug string, kind models.CategoryKind) string {
func BuildProjectRootResourceUrl(projectSlug string, kind models.ThreadType) string {
switch kind {
case models.CatKindBlog:
case models.ThreadTypeProjectArticle:
return hmnurl.BuildBlog(projectSlug, 1)
case models.CatKindForum:
return hmnurl.BuildForumCategory(projectSlug, nil, 1)
case models.CatKindWiki:
return hmnurl.BuildWiki(projectSlug)
case models.CatKindLibraryResource:
return hmnurl.BuildLibrary(projectSlug)
case models.ThreadTypeForumPost:
return hmnurl.BuildForum(projectSlug, nil, 1)
}
return hmnurl.BuildProjectHomepage(projectSlug)
}

View File

@ -20,8 +20,6 @@ type UserProfileTemplateData struct {
TimelineItems []templates.TimelineItem
NumForums int
NumBlogs int
NumWiki int
NumLibrary int
NumSnippets int
}
@ -115,10 +113,9 @@ func UserProfile(c *RequestContext) ResponseData {
c.Perf.EndBlock()
type postQuery struct {
Post models.Post `db:"post"`
Thread models.Thread `db:"thread"`
LibraryResource *models.LibraryResource `db:"lib_resource"`
Project models.Project `db:"project"`
Post models.Post `db:"post"`
Thread models.Thread `db:"thread"`
Project models.Project `db:"project"`
}
c.Perf.StartBlock("SQL", "Fetch posts")
postQueryResult, err := db.Query(c.Context(), c.Conn, postQuery{},
@ -128,7 +125,6 @@ func UserProfile(c *RequestContext) ResponseData {
handmade_post AS post
INNER JOIN handmade_thread AS thread ON thread.id = post.thread_id
INNER JOIN handmade_project AS project ON project.id = post.project_id
LEFT JOIN handmade_libraryresource AS lib_resource ON lib_resource.category_id = post.category_id
WHERE
post.author_id = $1
AND project.lifecycle = ANY ($2)
@ -142,37 +138,6 @@ func UserProfile(c *RequestContext) ResponseData {
postQuerySlice := postQueryResult.ToSlice()
c.Perf.EndBlock()
type wikiEditQuery struct {
PostVersion models.PostVersion `db:"version"`
Post models.Post `db:"post"`
Thread models.Thread `db:"thread"`
Project models.Project `db:"project"`
}
c.Perf.StartBlock("SQL", "Fetch wiki edits")
wikiEditQueryResult, err := db.Query(c.Context(), c.Conn, wikiEditQuery{},
`
SELECT $columns
FROM
handmade_postversion AS version
INNER JOIN handmade_post AS post ON post.id = version.post_id
INNER JOIN handmade_thread AS thread on thread.id = post.thread_id
INNER JOIN handmade_project AS project ON project.id = post.project_id
WHERE
version.editor_id = $1
AND post.parent_id IS NULL
AND post.category_kind = $2
AND project.lifecycle = ANY ($3)
`,
profileUser.ID,
models.CatKindWiki,
models.VisibleProjectLifecycles,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch wiki edits for user: %s", username))
}
wikiEditQuerySlice := wikiEditQueryResult.ToSlice()
c.Perf.EndBlock()
type snippetQuery struct {
Snippet models.Snippet `db:"snippet"`
Asset *models.Asset `db:"asset"`
@ -197,17 +162,15 @@ func UserProfile(c *RequestContext) ResponseData {
snippetQuerySlice := snippetQueryResult.ToSlice()
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock()
c.Perf.StartBlock("PROFILE", "Construct timeline items")
timelineItems := make([]templates.TimelineItem, 0, len(postQuerySlice)+len(wikiEditQuerySlice)+len(snippetQuerySlice))
timelineItems := make([]templates.TimelineItem, 0, len(postQuerySlice)+len(snippetQuerySlice))
numForums := 0
numBlogs := 0
numWiki := len(wikiEditQuerySlice)
numLibrary := 0
numSnippets := len(snippetQuerySlice)
for _, postRow := range postQuerySlice {
@ -217,7 +180,6 @@ func UserProfile(c *RequestContext) ResponseData {
&postData.Post,
&postData.Thread,
&postData.Project,
postData.LibraryResource,
profileUser,
c.Theme,
)
@ -226,10 +188,6 @@ func UserProfile(c *RequestContext) ResponseData {
numForums += 1
case templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment:
numBlogs += 1
case templates.TimelineTypeWikiCreate, templates.TimelineTypeWikiTalk:
numWiki += 1
case templates.TimelineTypeLibraryComment:
numLibrary += 1
}
if timelineItem.Type != templates.TimelineTypeUnknown {
timelineItems = append(timelineItems, timelineItem)
@ -238,20 +196,6 @@ func UserProfile(c *RequestContext) ResponseData {
}
}
for _, wikiEditRow := range wikiEditQuerySlice {
wikiEditData := wikiEditRow.(*wikiEditQuery)
timelineItem := PostVersionToWikiTimelineItem(
lineageBuilder,
&wikiEditData.PostVersion,
&wikiEditData.Post,
&wikiEditData.Thread,
&wikiEditData.Project,
profileUser,
c.Theme,
)
timelineItems = append(timelineItems, timelineItem)
}
for _, snippetRow := range snippetQuerySlice {
snippetData := snippetRow.(*snippetQuery)
timelineItem := SnippetToTimelineItem(
@ -282,8 +226,6 @@ func UserProfile(c *RequestContext) ResponseData {
TimelineItems: timelineItems,
NumForums: numForums,
NumBlogs: numBlogs,
NumWiki: numWiki,
NumLibrary: numLibrary,
NumSnippets: numSnippets,
}, c.Perf)
return res