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, FORUM_REPLY: 2,
BLOG_POST: 3, BLOG_POST: 3,
BLOG_COMMENT: 4, BLOG_COMMENT: 4,
WIKI_CREATE: 5, SNIPPET_IMAGE: 5,
WIKI_EDIT: 6, SNIPPET_VIDEO: 6,
WIKI_TALK: 7, SNIPPET_AUDIO: 7,
LIBRARY_COMMENT: 8, SNIPPET_YOUTUBE: 8
SNIPPET_IMAGE: 9,
SNIPPET_VIDEO: 10,
SNIPPET_AUDIO: 11,
SNIPPET_YOUTUBE: 12
}; };
const showcaseItemTemplate = makeTemplateCloner("showcase_item"); const showcaseItemTemplate = makeTemplateCloner("showcase_item");

View File

@ -8634,11 +8634,6 @@ input[type=submit] {
padding-left: 10px; padding-left: 10px;
max-width: 80em; } max-width: 80em; }
.wiki .post {
padding: 0;
margin: auto;
max-width: 70em; }
.post .contents h1, .post .contents h2 { .post .contents h1, .post .contents h2 {
margin: 20px 0px; } margin: 20px 0px; }
@ -8750,56 +8745,11 @@ input[type=submit] {
.blog .post-list .post:nth-child(even) { .blog .post-list .post:nth-child(even) {
background-color: transparent; } 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 { .featured-post .meta .avatar-icon {
left: -60px; left: -60px;
bottom: -5px; } bottom: -5px; }
.blog .body blockquote, .blog .body blockquote {
.wiki .body blockquote {
padding-top: 1px; padding-top: 1px;
padding-bottom: 1px; } padding-bottom: 1px; }
@ -9446,9 +9396,6 @@ span.icon-rss::before {
.timeline.no-blogs .blogs { .timeline.no-blogs .blogs {
display: none; } display: none; }
.timeline.no-wiki .wiki {
display: none; }
.timeline.no-library .library { .timeline.no-library .library {
display: none; } display: none; }

View File

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

View File

@ -318,8 +318,6 @@ will throw an error.
--irc-users-popout-background: #fff; --irc-users-popout-background: #fff;
--irc-users-popout-border-color-left: #bbb; --irc-users-popout-border-color-left: #bbb;
--irc-users-popout-border-color-right: #ccc; --irc-users-popout-border-color-right: #ccc;
--wiki-border-color: #aaa;
--wiki-toc-number-color: #333;
--code-line-number-color: #777; --code-line-number-color: #777;
--library-star-btn-background: #fff; --library-star-btn-background: #fff;
--library-star-btn-border-color: #999; --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. Values of these kinds are ok to query even if they are not directly understood by pgtype.
This is common for custom types like: This is common for custom types like:
type CategoryKind int type ThreadType int
*/ */
var queryableKinds = []reflect.Kind{ var queryableKinds = []reflect.Kind{
reflect.Int, reflect.Int,

View File

@ -145,22 +145,22 @@ func TestPodcastRSS(t *testing.T) {
AssertRegexMatch(t, BuildPodcastRSS(""), RegexPodcastRSS, nil) AssertRegexMatch(t, BuildPodcastRSS(""), RegexPodcastRSS, nil)
} }
func TestForumCategory(t *testing.T) { func TestForum(t *testing.T) {
AssertRegexMatch(t, BuildForumCategory("", nil, 1), RegexForumCategory, nil) AssertRegexMatch(t, BuildForum("", nil, 1), RegexForum, nil)
AssertRegexMatch(t, BuildForumCategory("", []string{"wip"}, 2), RegexForumCategory, map[string]string{"cats": "wip", "page": "2"}) AssertRegexMatch(t, BuildForum("", []string{"wip"}, 2), RegexForum, map[string]string{"subforums": "wip", "page": "2"})
AssertRegexMatch(t, BuildForumCategory("", []string{"sub", "wip"}, 2), RegexForumCategory, map[string]string{"cats": "sub/wip", "page": "2"}) AssertRegexMatch(t, BuildForum("", []string{"sub", "wip"}, 2), RegexForum, map[string]string{"subforums": "sub/wip", "page": "2"})
AssertSubdomain(t, BuildForumCategory("hmn", nil, 1), "") AssertSubdomain(t, BuildForum("hmn", nil, 1), "")
AssertSubdomain(t, BuildForumCategory("", nil, 1), "") AssertSubdomain(t, BuildForum("", nil, 1), "")
AssertSubdomain(t, BuildForumCategory("hero", nil, 1), "hero") AssertSubdomain(t, BuildForum("hero", nil, 1), "hero")
assert.Panics(t, func() { BuildForumCategory("", nil, 0) }) assert.Panics(t, func() { BuildForum("", nil, 0) })
assert.Panics(t, func() { BuildForumCategory("", []string{"", "wip"}, 1) }) assert.Panics(t, func() { BuildForum("", []string{"", "wip"}, 1) })
assert.Panics(t, func() { BuildForumCategory("", []string{" ", "wip"}, 1) }) assert.Panics(t, func() { BuildForum("", []string{" ", "wip"}, 1) })
assert.Panics(t, func() { BuildForumCategory("", []string{"wip/jobs"}, 1) }) assert.Panics(t, func() { BuildForum("", []string{"wip/jobs"}, 1) })
} }
func TestForumNewThread(t *testing.T) { 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"}, false), RegexForumNewThread, map[string]string{"subforums": "sub/wip"})
AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}, true), RegexForumNewThreadSubmit, map[string]string{"cats": "sub/wip"}) AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}, true), RegexForumNewThreadSubmit, map[string]string{"subforums": "sub/wip"})
} }
func TestForumThread(t *testing.T) { func TestForumThread(t *testing.T) {
@ -197,12 +197,6 @@ func TestForumPostReply(t *testing.T) {
AssertSubdomain(t, BuildForumPostReply("hero", nil, 1, 2), "hero") 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) { func TestBlog(t *testing.T) {
AssertRegexMatch(t, BuildBlog("", 1), RegexBlog, nil) AssertRegexMatch(t, BuildBlog("", 1), RegexBlog, nil)
AssertRegexMatch(t, BuildBlog("", 2), RegexBlog, map[string]string{"page": "2"}) 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") 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) { func TestLibrary(t *testing.T) {
AssertRegexMatch(t, BuildLibrary(""), RegexLibrary, nil) AssertRegexMatch(t, BuildLibrary(""), RegexLibrary, nil)
AssertSubdomain(t, BuildLibrary("hero"), "hero") AssertSubdomain(t, BuildLibrary("hero"), "hero")
@ -344,38 +262,6 @@ func TestLibraryResource(t *testing.T) {
AssertSubdomain(t, BuildLibraryResource("hero", 1), "hero") 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) { func TestProjectCSS(t *testing.T) {
AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil) AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil)
} }
@ -395,8 +281,8 @@ func TestPublic(t *testing.T) {
AssertRegexMatch(t, BuildUserFile("mylogo.png"), RegexPublic, nil) AssertRegexMatch(t, BuildUserFile("mylogo.png"), RegexPublic, nil)
} }
func TestForumCategoryMarkRead(t *testing.T) { func TestForumMarkRead(t *testing.T) {
AssertRegexMatch(t, BuildForumCategoryMarkRead(5), RegexForumCategoryMarkRead, map[string]string{"catid": "5"}) AssertRegexMatch(t, BuildForumMarkRead(5), RegexForumMarkRead, map[string]string{"sfid": "5"})
} }
func AssertSubdomain(t *testing.T, fullUrl string, expectedSubdomain string) { 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) match := regex.FindStringSubmatch(requestPath)
assert.Nilf(t, match, "Url matched regex: [%s] vs [%s]", requestPath, regex.String()) 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 * Forums
*/ */
// TODO(asaf): This also matches urls generated by BuildForumThread (/t/ is identified as a cat, and the threadid as a page) // 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 Category in the router, but should we enforce it here? // This shouldn't be a problem since we will match Thread before Subforum in the router, but should we enforce it here?
var RegexForumCategory = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?(/(?P<page>\d+))?$`) 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() defer CatchPanic()
if page < 1 { if page < 1 {
panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page)) panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page))
} }
builder := buildForumCategoryPath(subforums) builder := buildSubforumPath(subforums)
if page > 1 { if page > 1 {
builder.WriteRune('/') builder.WriteRune('/')
@ -325,12 +325,12 @@ func BuildForumCategory(projectSlug string, subforums []string, page int) string
return ProjectUrl(builder.String(), nil, projectSlug) return ProjectUrl(builder.String(), nil, projectSlug)
} }
var RegexForumNewThread = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/new$`) var RegexForumNewThread = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/new$`)
var RegexForumNewThreadSubmit = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/new/submit$`) var RegexForumNewThreadSubmit = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/new/submit$`)
func BuildForumNewThread(projectSlug string, subforums []string, submit bool) string { func BuildForumNewThread(projectSlug string, subforums []string, submit bool) string {
defer CatchPanic() defer CatchPanic()
builder := buildForumCategoryPath(subforums) builder := buildSubforumPath(subforums)
builder.WriteString("/t/new") builder.WriteString("/t/new")
if submit { if submit {
builder.WriteString("/submit") builder.WriteString("/submit")
@ -339,7 +339,7 @@ func BuildForumNewThread(projectSlug string, subforums []string, submit bool) st
return ProjectUrl(builder.String(), nil, projectSlug) 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 { func BuildForumThread(projectSlug string, subforums []string, threadId int, title string, page int) string {
defer CatchPanic() defer CatchPanic()
@ -355,7 +355,7 @@ func BuildForumThreadWithPostHash(projectSlug string, subforums []string, thread
return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId)) 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 { func BuildForumPost(projectSlug string, subforums []string, threadId int, postId int) string {
defer CatchPanic() defer CatchPanic()
@ -364,7 +364,7 @@ func BuildForumPost(projectSlug string, subforums []string, threadId int, postId
return ProjectUrl(builder.String(), nil, projectSlug) 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 { func BuildForumPostDelete(projectSlug string, subforums []string, threadId int, postId int) string {
defer CatchPanic() defer CatchPanic()
@ -373,7 +373,7 @@ func BuildForumPostDelete(projectSlug string, subforums []string, threadId int,
return ProjectUrl(builder.String(), nil, projectSlug) 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 { func BuildForumPostEdit(projectSlug string, subforums []string, threadId int, postId int) string {
defer CatchPanic() defer CatchPanic()
@ -382,7 +382,7 @@ func BuildForumPostEdit(projectSlug string, subforums []string, threadId int, po
return ProjectUrl(builder.String(), nil, projectSlug) 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? // 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 { 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) 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 * Library
*/ */
@ -671,74 +514,6 @@ func BuildLibraryResource(projectSlug string, resourceId int) string {
return ProjectUrl(builder.String(), nil, projectSlug) 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 * Assets
*/ */
@ -803,18 +578,18 @@ func BuildUserFile(filepath string) string {
* Other * Other
*/ */
var RegexForumCategoryMarkRead = regexp.MustCompile(`^/markread/(?P<catid>\d+)$`) var RegexForumMarkRead = regexp.MustCompile(`^/markread/(?P<sfid>\d+)$`)
// NOTE(asaf): categoryId == 0 means ALL CATEGORIES // NOTE(asaf): subforumId == 0 means ALL SUBFORUMS
func BuildForumCategoryMarkRead(categoryId int) string { func BuildForumMarkRead(subforumId int) string {
defer CatchPanic() defer CatchPanic()
if categoryId < 0 { if subforumId < 0 {
panic(oops.New(nil, "Invalid category ID (%d), must be >= 0", categoryId)) panic(oops.New(nil, "Invalid subforum ID (%d), must be >= 0", subforumId))
} }
var builder strings.Builder var builder strings.Builder
builder.WriteString("/markread/") builder.WriteString("/markread/")
builder.WriteString(strconv.Itoa(categoryId)) builder.WriteString(strconv.Itoa(subforumId))
return Url(builder.String(), nil) return Url(builder.String(), nil)
} }
@ -825,7 +600,7 @@ var RegexCatchAll = regexp.MustCompile("")
* Helper functions * Helper functions
*/ */
func buildForumCategoryPath(subforums []string) *strings.Builder { func buildSubforumPath(subforums []string) *strings.Builder {
for _, subforum := range subforums { for _, subforum := range subforums {
if strings.Contains(subforum, "/") { if strings.Contains(subforum, "/") {
panic(oops.New(nil, "Tried building forum url with / in subforum name")) 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)) panic(oops.New(nil, "Invalid forum thread ID (%d), must be >= 1", threadId))
} }
builder := buildForumCategoryPath(subforums) builder := buildSubforumPath(subforums)
builder.WriteString("/t/") builder.WriteString("/t/")
builder.WriteString(strconv.Itoa(threadId)) 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)) panic(oops.New(nil, "Invalid forum post ID (%d), must be >= 1", postId))
} }
builder := buildForumCategoryPath(subforums) builder := buildSubforumPath(subforums)
builder.WriteString("/t/") builder.WriteString("/t/")
builder.WriteString(strconv.Itoa(threadId)) builder.WriteString(strconv.Itoa(threadId))
@ -934,34 +709,6 @@ func buildBlogPostPath(threadId int, postId int) *strings.Builder {
return &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 { func buildLibraryResourcePath(resourceId int) *strings.Builder {
if resourceId < 1 { if resourceId < 1 {
panic(oops.New(nil, "Invalid library resource ID (%d), must be >= 1", resourceId)) panic(oops.New(nil, "Invalid library resource ID (%d), must be >= 1", resourceId))

View File

@ -5,7 +5,6 @@ import (
"time" "time"
"git.handmade.network/hmn/hmn/src/migration/types" "git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4" "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 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) var All map[types.MigrationVersion]types.Migration = make(map[types.MigrationVersion]types.Migration)
func registerMigration(m types.Migration) { func registerMigration(m types.Migration) {
All[m.Version()] = m 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 { type LibraryResource struct {
ID int `db:"id"` ID int `db:"id"`
CategoryID int `db:"category_id"` ProjectID *int `db:"project_id"`
ProjectID *int `db:"project_id"`
Name string `db:"name"` Name string `db:"name"`
Description string `db:"description"` Description string `db:"description"`

View File

@ -9,14 +9,13 @@ type Post struct {
ID int `db:"id"` ID int `db:"id"`
// TODO: Document each of these // TODO: Document each of these
AuthorID *int `db:"author_id"` AuthorID *int `db:"author_id"`
CategoryID int `db:"category_id"` ParentID *int `db:"parent_id"`
ParentID *int `db:"parent_id"` ThreadID int `db:"thread_id"`
ThreadID int `db:"thread_id"` CurrentID int `db:"current_id"`
CurrentID int `db:"current_id"` ProjectID int `db:"project_id"`
ProjectID int `db:"project_id"`
CategoryKind CategoryKind `db:"category_kind"` ThreadType ThreadType `db:"thread_type"`
PostDate time.Time `db:"postdate"` PostDate time.Time `db:"postdate"`
Deleted bool `db:"deleted"` 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 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 { type Thread struct {
ID int `db:"id"` 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"` Title string `db:"title"`
Sticky bool `db:"sticky"` Sticky bool `db:"sticky"`

View File

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

View File

@ -157,13 +157,6 @@
} }
.post { .post {
.wiki &,
{
padding: 0;
margin: auto;
max-width: 70em;
}
.contents { .contents {
h1, h2 { h1, h2 {
margin: 20px 0px; 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 { .featured-post .meta .avatar-icon {
left:-60px; left:-60px;
bottom:-5px; bottom:-5px;
} }
.blog .body blockquote, .blog .body blockquote {
.wiki .body blockquote {
padding-top:1px; padding-top:1px;
padding-bottom:1px; padding-bottom:1px;
} }

View File

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

View File

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

View File

@ -112,9 +112,6 @@ $vars: (
irc-users-popout-border-color-left: #bbb, irc-users-popout-border-color-left: #bbb,
irc-users-popout-border-color-right: #ccc, irc-users-popout-border-color-right: #ccc,
wiki-border-color: #aaa,
wiki-toc-number-color: #333,
code-line-number-color: #777, code-line-number-color: #777,
library-star-btn-background: #fff, 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 HasBlog: true, // TODO: Check flag sets or whatever
HasForum: true, HasForum: true,
HasWiki: true,
HasLibrary: true, HasLibrary: true,
DateApproved: p.DateApproved, DateApproved: p.DateApproved,

View File

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

View File

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

View File

@ -172,166 +172,6 @@
</div> </div>
{{ end }} {{ 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" }} {{ define "landing_page_featured_post" }}
{{/* Call this template with a LandingPageFeaturedPost. */}} {{/* Call this template with a LandingPageFeaturedPost. */}}
<div class="flex items-start ph3 pv2 {{ if .Unread }}unread{{ else }}read{{ end }}"> <div class="flex items-start ph3 pv2 {{ if .Unread }}unread{{ else }}read{{ end }}">

View File

@ -25,12 +25,6 @@
{{ if gt .NumBlogs 0 }} {{ 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> <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 }} {{ 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 }} {{ 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> <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 }} {{ end }}
@ -61,7 +55,7 @@
<div class="pair flex flex-wrap"> <div class="pair flex flex-wrap">
<div class="key flex-auto mr1">Posts</div> <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> </div>
{{ if .ProfileUser.Email }} {{ if .ProfileUser.Email }}

View File

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

View File

@ -39,7 +39,6 @@ type Header struct {
ProjectIndexUrl string ProjectIndexUrl string
BlogUrl string BlogUrl string
ForumsUrl string ForumsUrl string
WikiUrl string
LibraryUrl string LibraryUrl string
ManifestoUrl string ManifestoUrl string
EpisodeGuideUrl string EpisodeGuideUrl string
@ -109,7 +108,6 @@ type Project struct {
HasBlog bool HasBlog bool
HasForum bool HasForum bool
HasWiki bool
HasLibrary bool HasLibrary bool
UUID string UUID string
@ -202,10 +200,6 @@ const (
PostTypeBlogComment PostTypeBlogComment
PostTypeForumThread PostTypeForumThread
PostTypeForumReply PostTypeForumReply
PostTypeWikiCreate
PostTypeWikiTalk
PostTypeWikiEdit
PostTypeLibraryComment
) )
// Data from post_list_item.html // Data from post_list_item.html
@ -253,12 +247,6 @@ const (
TimelineTypeBlogPost TimelineTypeBlogPost
TimelineTypeBlogComment TimelineTypeBlogComment
TimelineTypeWikiCreate
TimelineTypeWikiEdit
TimelineTypeWikiTalk
TimelineTypeLibraryComment
TimelineTypeSnippetImage TimelineTypeSnippetImage
TimelineTypeSnippetVideo TimelineTypeSnippetVideo
TimelineTypeSnippetAudio 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 FROM
handmade_post AS post handmade_post AS post
WHERE WHERE
post.category_kind = ANY ($1) post.thread_type = ANY ($1)
AND deleted = FALSE AND deleted = FALSE
AND post.thread_id IS NOT NULL 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() c.Perf.EndBlock()
if err != nil { if err != nil {
@ -80,9 +80,9 @@ func Feed(c *RequestContext) ResponseData {
currentUserId = &c.CurrentUser.ID currentUserId = &c.CurrentUser.ID
} }
c.Perf.StartBlock("SQL", "Fetch category tree") c.Perf.StartBlock("SQL", "Fetch subforum tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock() c.Perf.EndBlock()
posts, err := fetchAllPosts(c, lineageBuilder, currentUserId, howManyPostsToSkip, postsPerPage) posts, err := fetchAllPosts(c, lineageBuilder, currentUserId, howManyPostsToSkip, postsPerPage)
@ -98,7 +98,7 @@ func Feed(c *RequestContext) ResponseData {
BaseData: baseData, BaseData: baseData,
AtomFeedUrl: hmnurl.BuildAtomFeed(), AtomFeedUrl: hmnurl.BuildAtomFeed(),
MarkAllReadUrl: hmnurl.BuildForumCategoryMarkRead(0), MarkAllReadUrl: hmnurl.BuildForumMarkRead(0),
Posts: posts, Posts: posts,
Pagination: pagination, Pagination: pagination,
}, c.Perf) }, c.Perf)
@ -158,9 +158,9 @@ func AtomFeed(c *RequestContext) ResponseData {
feedData.AtomFeedUrl = hmnurl.BuildAtomFeed() feedData.AtomFeedUrl = hmnurl.BuildAtomFeed()
feedData.FeedUrl = hmnurl.BuildFeed() feedData.FeedUrl = hmnurl.BuildFeed()
c.Perf.StartBlock("SQL", "Fetch category tree") c.Perf.StartBlock("SQL", "Fetch subforum tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock() c.Perf.EndBlock()
posts, err := fetchAllPosts(c, lineageBuilder, nil, 0, itemsPerFeed) posts, err := fetchAllPosts(c, lineageBuilder, nil, 0, itemsPerFeed)
@ -303,18 +303,16 @@ func AtomFeed(c *RequestContext) ResponseData {
return res 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") c.Perf.StartBlock("SQL", "Fetch posts")
type feedPostQuery struct { type feedPostQuery struct {
Post models.Post `db:"post"` Post models.Post `db:"post"`
PostVersion models.PostVersion `db:"version"` PostVersion models.PostVersion `db:"version"`
Thread models.Thread `db:"thread"` Thread models.Thread `db:"thread"`
Cat models.Category `db:"cat"` Proj models.Project `db:"proj"`
Proj models.Project `db:"proj"` User models.User `db:"auth_user"`
LibraryResource *models.LibraryResource `db:"lib_resource"` ThreadLastReadTime *time.Time `db:"tlri.lastread"`
User models.User `db:"auth_user"` SubforumLastReadTime *time.Time `db:"slri.lastread"`
ThreadLastReadTime *time.Time `db:"tlri.lastread"`
CatLastReadTime *time.Time `db:"clri.lastread"`
} }
posts, err := db.Query(c.Context(), c.Conn, feedPostQuery{}, posts, err := db.Query(c.Context(), c.Conn, feedPostQuery{},
` `
@ -323,27 +321,25 @@ func fetchAllPosts(c *RequestContext, lineageBuilder *models.CategoryLineageBuil
handmade_post AS post handmade_post AS post
JOIN handmade_postversion AS version ON version.id = post.current_id JOIN handmade_postversion AS version ON version.id = post.current_id
JOIN handmade_thread AS thread ON thread.id = post.thread_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 JOIN handmade_project AS proj ON proj.id = post.project_id
LEFT JOIN handmade_threadlastreadinfo AS tlri ON ( LEFT JOIN handmade_threadlastreadinfo AS tlri ON (
tlri.thread_id = post.thread_id tlri.thread_id = post.thread_id
AND tlri.user_id = $1 AND tlri.user_id = $1
) )
LEFT JOIN handmade_categorylastreadinfo AS clri ON ( LEFT JOIN handmade_subforumlastreadinfo AS slri ON (
clri.category_id = post.category_id slri.subforum_id = thread.subforum_id
AND clri.user_id = $1 AND slri.user_id = $1
) )
LEFT JOIN auth_user ON post.author_id = auth_user.id 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 WHERE
post.category_kind = ANY ($2) thread.type = ANY ($2)
AND post.deleted = FALSE AND post.deleted = FALSE
AND post.thread_id IS NOT NULL AND post.thread_id IS NOT NULL
ORDER BY postdate DESC ORDER BY postdate DESC
LIMIT $3 OFFSET $4 LIMIT $3 OFFSET $4
`, `,
currentUserID, currentUserID,
[]models.CategoryKind{models.CatKindForum, models.CatKindBlog, models.CatKindWiki, models.CatKindLibraryResource}, []models.ThreadType{models.ThreadTypeForumPost, models.ThreadTypeProjectArticle},
limit, limit,
offset, offset,
) )
@ -360,7 +356,7 @@ func fetchAllPosts(c *RequestContext, lineageBuilder *models.CategoryLineageBuil
hasRead := false hasRead := false
if postResult.ThreadLastReadTime != nil && postResult.ThreadLastReadTime.After(postResult.Post.PostDate) { if postResult.ThreadLastReadTime != nil && postResult.ThreadLastReadTime.After(postResult.Post.PostDate) {
hasRead = true 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 hasRead = true
} }
@ -370,7 +366,6 @@ func fetchAllPosts(c *RequestContext, lineageBuilder *models.CategoryLineageBuil
&postResult.Thread, &postResult.Thread,
&postResult.Post, &postResult.Post,
&postResult.User, &postResult.User,
postResult.LibraryResource,
!hasRead, !hasRead,
true, true,
c.Theme, c.Theme,

View File

@ -21,17 +21,17 @@ import (
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
) )
type forumCategoryData struct { type forumData struct {
templates.BaseData templates.BaseData
NewThreadUrl string NewThreadUrl string
MarkReadUrl string MarkReadUrl string
Threads []templates.ThreadListItem Threads []templates.ThreadListItem
Pagination templates.Pagination Pagination templates.Pagination
Subcategories []forumSubcategoryData Subforums []forumSubforumData
} }
type forumSubcategoryData struct { type forumSubforumData struct {
Name string Name string
Url string Url string
Threads []templates.ThreadListItem Threads []templates.ThreadListItem
@ -50,7 +50,7 @@ type editorData struct {
PostReplyingTo *templates.Post PostReplyingTo *templates.Post
} }
func ForumCategory(c *RequestContext) ResponseData { func Forum(c *RequestContext) ResponseData {
const threadsPerPage = 25 const threadsPerPage = 25
cd, ok := getCommonForumData(c) cd, ok := getCommonForumData(c)
@ -58,7 +58,7 @@ func ForumCategory(c *RequestContext) ResponseData {
return FourOhFour(c) return FourOhFour(c)
} }
currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID) currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID)
c.Perf.StartBlock("SQL", "Fetch count of page threads") c.Perf.StartBlock("SQL", "Fetch count of page threads")
numThreads, err := db.QueryInt(c.Context(), c.Conn, numThreads, err := db.QueryInt(c.Context(), c.Conn,
@ -66,10 +66,10 @@ func ForumCategory(c *RequestContext) ResponseData {
SELECT COUNT(*) SELECT COUNT(*)
FROM handmade_thread AS thread FROM handmade_thread AS thread
WHERE WHERE
thread.category_id = $1 thread.subforum_id = $1
AND NOT thread.deleted AND NOT thread.deleted
`, `,
cd.CatID, cd.SubforumID,
) )
if err != nil { if err != nil {
panic(oops.New(err, "failed to get count of threads")) 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 { if pageParsed, err := strconv.Atoi(pageString); err == nil {
page = pageParsed page = pageParsed
} else { } 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 { 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 howManyThreadsToSkip := (page - 1) * threadsPerPage
@ -106,7 +106,7 @@ func ForumCategory(c *RequestContext) ResponseData {
FirstUser *models.User `db:"firstuser"` FirstUser *models.User `db:"firstuser"`
LastUser *models.User `db:"lastuser"` LastUser *models.User `db:"lastuser"`
ThreadLastReadTime *time.Time `db:"tlri.lastread"` 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{}, itMainThreads, err := db.Query(c.Context(), c.Conn, threadQueryResult{},
` `
@ -121,17 +121,17 @@ func ForumCategory(c *RequestContext) ResponseData {
tlri.thread_id = thread.id tlri.thread_id = thread.id
AND tlri.user_id = $2 AND tlri.user_id = $2
) )
LEFT JOIN handmade_categorylastreadinfo AS clri ON ( LEFT JOIN handmade_subforumlastreadinfo AS slri ON (
clri.category_id = $1 slri.subforum_id = $1
AND clri.user_id = $2 AND slri.user_id = $2
) )
WHERE WHERE
thread.category_id = $1 thread.subforum_id = $1
AND NOT thread.deleted AND NOT thread.deleted
ORDER BY lastpost.postdate DESC ORDER BY lastpost.postdate DESC
LIMIT $3 OFFSET $4 LIMIT $3 OFFSET $4
`, `,
cd.CatID, cd.SubforumID,
currentUserId, currentUserId,
threadsPerPage, threadsPerPage,
howManyThreadsToSkip, howManyThreadsToSkip,
@ -146,13 +146,13 @@ func ForumCategory(c *RequestContext) ResponseData {
hasRead := false hasRead := false
if row.ThreadLastReadTime != nil && row.ThreadLastReadTime.After(row.LastPost.PostDate) { if row.ThreadLastReadTime != nil && row.ThreadLastReadTime.After(row.LastPost.PostDate) {
hasRead = true 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 hasRead = true
} }
return templates.ThreadListItem{ return templates.ThreadListItem{
Title: row.Thread.Title, 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), FirstUser: templates.UserToTemplate(row.FirstUser, c.Theme),
FirstDate: row.FirstPost.PostDate, FirstDate: row.FirstPost.PostDate,
LastUser: templates.UserToTemplate(row.LastUser, c.Theme), 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 { if page == 1 {
subcatNodes := cd.CategoryTree[cd.CatID].Children subforumNodes := cd.SubforumTree[cd.SubforumID].Children
for _, catNode := range subcatNodes { for _, sfNode := range subforumNodes {
c.Perf.StartBlock("SQL", "Fetch count of subcategory threads") c.Perf.StartBlock("SQL", "Fetch count of subforum threads")
// TODO(asaf): [PERF] [MINOR] Consider replacing querying count per subcat with a single query for all cats with GROUP BY. // 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, numThreads, err := db.QueryInt(c.Context(), c.Conn,
` `
SELECT COUNT(*) SELECT COUNT(*)
FROM handmade_thread AS thread FROM handmade_thread AS thread
WHERE WHERE
thread.category_id = $1 thread.subforum_id = $1
AND NOT thread.deleted AND NOT thread.deleted
`, `,
catNode.ID, sfNode.ID,
) )
if err != nil { if err != nil {
panic(oops.New(err, "failed to get count of threads")) panic(oops.New(err, "failed to get count of threads"))
} }
c.Perf.EndBlock() c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch subcategory threads") c.Perf.StartBlock("SQL", "Fetch subforum threads")
// TODO(asaf): [PERF] [MINOR] Consider batching these. // TODO(asaf): [PERF] [MINOR] Consider batching these.
itThreads, err := db.Query(c.Context(), c.Conn, threadQueryResult{}, itThreads, err := db.Query(c.Context(), c.Conn, threadQueryResult{},
` `
@ -209,17 +209,17 @@ func ForumCategory(c *RequestContext) ResponseData {
tlri.thread_id = thread.id tlri.thread_id = thread.id
AND tlri.user_id = $2 AND tlri.user_id = $2
) )
LEFT JOIN handmade_categorylastreadinfo AS clri ON ( LEFT JOIN handmade_subforumlastreadinfo AS slri ON (
clri.category_id = $1 slri.subforum_id = $1
AND clri.user_id = $2 AND slri.user_id = $2
) )
WHERE WHERE
thread.category_id = $1 thread.subforum_id = $1
AND NOT thread.deleted AND NOT thread.deleted
ORDER BY lastpost.postdate DESC ORDER BY lastpost.postdate DESC
LIMIT 3 LIMIT 3
`, `,
catNode.ID, sfNode.ID,
currentUserId, currentUserId,
) )
if err != nil { if err != nil {
@ -234,9 +234,9 @@ func ForumCategory(c *RequestContext) ResponseData {
threads = append(threads, makeThreadListItem(threadRow)) threads = append(threads, makeThreadListItem(threadRow))
} }
subcats = append(subcats, forumSubcategoryData{ subforums = append(subforums, forumSubforumData{
Name: *catNode.Name, Name: sfNode.Name,
Url: hmnurl.BuildForumCategory(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(catNode.ID), 1), Url: hmnurl.BuildForum(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(sfNode.ID), 1),
Threads: threads, Threads: threads,
TotalThreads: numThreads, TotalThreads: numThreads,
}) })
@ -249,53 +249,53 @@ func ForumCategory(c *RequestContext) ResponseData {
baseData := getBaseData(c) baseData := getBaseData(c)
baseData.Title = c.CurrentProject.Name + " Forums" 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, Name: c.CurrentProject.Name,
Url: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug), Url: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
}, },
{ {
Name: "Forums", Name: "Forums",
Url: hmnurl.BuildForumCategory(c.CurrentProject.Slug, nil, 1), Url: hmnurl.BuildForum(c.CurrentProject.Slug, nil, 1),
Current: true, Current: true,
}, },
} }
currentSubforums := cd.LineageBuilder.GetSubforumLineage(cd.CatID) currentSubforums := cd.LineageBuilder.GetSubforumLineage(cd.SubforumID)
for i, subforum := range currentSubforums { for i, subforum := range currentSubforums {
baseData.Breadcrumbs = append(baseData.Breadcrumbs, templates.Breadcrumb{ baseData.Breadcrumbs = append(baseData.Breadcrumbs, templates.Breadcrumb{
Name: *subforum.Name, // NOTE(asaf): All subforum categories must have names. Name: subforum.Name,
Url: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs[0:i+1], 1), Url: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs[0:i+1], 1),
}) })
} }
var res ResponseData var res ResponseData
res.MustWriteTemplate("forum_category.html", forumCategoryData{ res.MustWriteTemplate("forum.html", forumData{
BaseData: baseData, BaseData: baseData,
NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs, false), NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs, false),
MarkReadUrl: hmnurl.BuildForumCategoryMarkRead(cd.CatID), MarkReadUrl: hmnurl.BuildForumMarkRead(cd.SubforumID),
Threads: threads, Threads: threads,
Pagination: templates.Pagination{ Pagination: templates.Pagination{
Current: page, Current: page,
Total: numPages, Total: numPages,
FirstUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, 1), FirstUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1),
LastUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, numPages), LastUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, numPages),
NextUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page+1, numPages)), NextUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page+1, numPages)),
PreviousUrl: hmnurl.BuildForumCategory(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) }, c.Perf)
return res return res
} }
func ForumCategoryMarkRead(c *RequestContext) ResponseData { func ForumMarkRead(c *RequestContext) ResponseData {
c.Perf.StartBlock("SQL", "Fetch category tree") c.Perf.StartBlock("SQL", "Fetch subforum tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock() c.Perf.EndBlock()
catId, err := strconv.Atoi(c.PathParams["catid"]) sfId, err := strconv.Atoi(c.PathParams["sfid"])
if err != nil { if err != nil {
return FourOhFour(c) return FourOhFour(c)
} }
@ -307,28 +307,28 @@ func ForumCategoryMarkRead(c *RequestContext) ResponseData {
defer tx.Rollback(c.Context()) defer tx.Rollback(c.Context())
// TODO(ben): Rework this logic when we rework blogs, threads, etc. // TODO(ben): Rework this logic when we rework blogs, threads, etc.
catIds := []int{catId} sfIds := []int{sfId}
if catId == 0 { if sfId == 0 {
// Select all categories // Select all categories
type catIdResult struct { type sfIdResult struct {
CatID int `db:"id"` SubforumID int `db:"id"`
} }
cats, err := db.Query(c.Context(), tx, catIdResult{}, cats, err := db.Query(c.Context(), tx, sfIdResult{},
` `
SELECT $columns SELECT $columns
FROM handmade_category FROM handmade_subforum
WHERE kind = ANY ($1) WHERE kind = ANY ($1)
`, `,
[]models.CategoryKind{models.CatKindBlog, models.CatKindForum, models.CatKindWiki, models.CatKindLibraryResource}, []models.ThreadType{models.ThreadTypeProjectArticle, models.ThreadTypeForumPost},
) )
if err != nil { if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch category IDs for CLRI")) return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch category IDs for CLRI"))
} }
catIdResults := cats.ToSlice() catIdResults := cats.ToSlice()
catIds = make([]int, len(catIdResults)) sfIds = make([]int, len(catIdResults))
for i, res := range 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 ON CONFLICT (category_id, user_id) DO UPDATE
SET lastread = EXCLUDED.lastread SET lastread = EXCLUDED.lastread
`, `,
catIds, sfIds,
c.CurrentUser.ID, c.CurrentUser.ID,
time.Now(), time.Now(),
) )
@ -364,7 +364,7 @@ func ForumCategoryMarkRead(c *RequestContext) ResponseData {
category_id = ANY ($1) category_id = ANY ($1)
) )
`, `,
catIds, sfIds,
c.CurrentUser.ID, c.CurrentUser.ID,
) )
c.Perf.EndBlock() c.Perf.EndBlock()
@ -378,10 +378,10 @@ func ForumCategoryMarkRead(c *RequestContext) ResponseData {
} }
var redirUrl string var redirUrl string
if catId == 0 { if sfId == 0 {
redirUrl = hmnurl.BuildFeed() redirUrl = hmnurl.BuildFeed()
} else { } 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) return c.Redirect(redirUrl, http.StatusSeeOther)
} }
@ -392,7 +392,7 @@ type forumThreadData struct {
Thread templates.Thread Thread templates.Thread
Posts []templates.Post Posts []templates.Post
CategoryUrl string SubforumUrl string
ReplyUrl string ReplyUrl string
Pagination templates.Pagination Pagination templates.Pagination
} }
@ -405,7 +405,7 @@ func ForumThread(c *RequestContext) ResponseData {
return FourOhFour(c) return FourOhFour(c)
} }
currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID) currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID)
thread := cd.FetchThread(c.Context(), c.Conn) thread := cd.FetchThread(c.Context(), c.Conn)
@ -519,7 +519,7 @@ func ForumThread(c *RequestContext) ResponseData {
BaseData: baseData, BaseData: baseData,
Thread: templates.ThreadToTemplate(thread), Thread: templates.ThreadToTemplate(thread),
Posts: posts, 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), ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, *thread.FirstID),
Pagination: pagination, Pagination: pagination,
}, c.Perf) }, c.Perf)
@ -542,12 +542,10 @@ func ForumPostRedirect(c *RequestContext) ResponseData {
FROM FROM
handmade_post AS post handmade_post AS post
WHERE WHERE
post.category_id = $1 post.thread_id = $1
AND post.thread_id = $2
AND NOT post.deleted AND NOT post.deleted
ORDER BY postdate ORDER BY postdate
`, `,
cd.CatID,
cd.ThreadID, cd.ThreadID,
) )
if err != nil { if err != nil {
@ -588,7 +586,7 @@ func ForumPostRedirect(c *RequestContext) ResponseData {
return c.Redirect(hmnurl.BuildForumThreadWithPostHash( return c.Redirect(hmnurl.BuildForumThreadWithPostHash(
c.CurrentProject.Slug, c.CurrentProject.Slug,
cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID),
cd.ThreadID, cd.ThreadID,
threadTitle, threadTitle,
page, page,
@ -610,7 +608,7 @@ func ForumNewThread(c *RequestContext) ResponseData {
var res ResponseData var res ResponseData
res.MustWriteTemplate("editor.html", editorData{ res.MustWriteTemplate("editor.html", editorData{
BaseData: baseData, 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", SubmitLabel: "Post New Thread",
}, c.Perf) }, c.Perf)
return res return res
@ -643,13 +641,15 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
var threadId int var threadId int
err = tx.QueryRow(c.Context(), err = tx.QueryRow(c.Context(),
` `
INSERT INTO handmade_thread (title, sticky, category_id, first_id, last_id) INSERT INTO handmade_thread (title, sticky, type, project_id, subforum_id, first_id, last_id)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id RETURNING id
`, `,
title, title,
sticky, sticky,
cd.CatID, models.ThreadTypeForumPost,
c.CurrentProject.ID,
cd.SubforumID,
-1, -1,
-1, -1,
).Scan(&threadId) ).Scan(&threadId)
@ -657,7 +657,7 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
panic(oops.New(err, "failed to create thread")) 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 // Update thread with post id
_, err = tx.Exec(c.Context(), _, 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")) 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) return c.Redirect(newThreadUrl, http.StatusSeeOther)
} }
@ -693,7 +693,7 @@ func ForumPostReply(c *RequestContext) ResponseData {
postData := cd.FetchPostAndStuff(c.Context(), c.Conn) postData := cd.FetchPostAndStuff(c.Context(), c.Conn)
baseData := getBaseData(c) 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 baseData.MathjaxEnabled = true
// TODO(ben): Set breadcrumbs // TODO(ben): Set breadcrumbs
@ -703,7 +703,7 @@ func ForumPostReply(c *RequestContext) ResponseData {
var res ResponseData var res ResponseData
res.MustWriteTemplate("editor.html", editorData{ res.MustWriteTemplate("editor.html", editorData{
BaseData: baseData, 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", SubmitLabel: "Submit Reply",
Title: "Replying to post", Title: "Replying to post",
@ -729,14 +729,14 @@ func ForumPostReplySubmit(c *RequestContext) ResponseData {
unparsed := c.Req.Form.Get("body") 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()) err = tx.Commit(c.Context())
if err != nil { if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to forum post")) 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) return c.Redirect(newPostUrl, http.StatusSeeOther)
} }
@ -753,7 +753,7 @@ func ForumPostEdit(c *RequestContext) ResponseData {
postData := cd.FetchPostAndStuff(c.Context(), c.Conn) postData := cd.FetchPostAndStuff(c.Context(), c.Conn)
baseData := getBaseData(c) 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 baseData.MathjaxEnabled = true
// TODO(ben): Set breadcrumbs // TODO(ben): Set breadcrumbs
@ -763,7 +763,7 @@ func ForumPostEdit(c *RequestContext) ResponseData {
var res ResponseData var res ResponseData
res.MustWriteTemplate("editor.html", editorData{ res.MustWriteTemplate("editor.html", editorData{
BaseData: baseData, 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, Title: postData.Thread.Title,
SubmitLabel: "Submit Edited Post", 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")) 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) return c.Redirect(postUrl, http.StatusSeeOther)
} }
@ -818,7 +818,7 @@ func ForumPostDelete(c *RequestContext) ResponseData {
postData := cd.FetchPostAndStuff(c.Context(), c.Conn) postData := cd.FetchPostAndStuff(c.Context(), c.Conn)
baseData := getBaseData(c) 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 baseData.MathjaxEnabled = true
// TODO(ben): Set breadcrumbs // TODO(ben): Set breadcrumbs
@ -834,7 +834,7 @@ func ForumPostDelete(c *RequestContext) ResponseData {
var res ResponseData var res ResponseData
res.MustWriteTemplate("forum_post_delete.html", forumPostDeleteData{ res.MustWriteTemplate("forum_post_delete.html", forumPostDeleteData{
BaseData: baseData, 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, Post: templatePost,
}, c.Perf) }, c.Perf)
return res 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")) 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) 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")) 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) 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 // Create post
err := tx.QueryRow(ctx, err := tx.QueryRow(ctx,
` `
INSERT INTO handmade_post (postdate, category_id, thread_id, current_id, author_id, category_kind, project_id, reply_id, preview) 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, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id RETURNING id
`, `,
time.Now(), time.Now(),
catId,
threadId, threadId,
models.ThreadTypeForumPost,
-1, -1,
userId, userId,
models.CatKindForum,
projectId, projectId,
replyId, replyId,
"", // empty preview, will be updated later "", // empty preview, will be updated later
@ -1064,16 +1063,16 @@ func fixThreadPostIds(ctx context.Context, tx pgx.Tx, threadId int) error {
type commonForumData struct { type commonForumData struct {
c *RequestContext c *RequestContext
CatID int SubforumID int
ThreadID int ThreadID int
PostID int PostID int
CategoryTree models.CategoryTree SubforumTree models.SubforumTree
LineageBuilder *models.CategoryLineageBuilder 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. 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. 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") c.Perf.StartBlock("FORUMS", "Fetch common forum data")
defer c.Perf.EndBlock() defer c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch category tree") c.Perf.StartBlock("SQL", "Fetch subforum tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock() c.Perf.EndBlock()
res := commonForumData{ res := commonForumData{
c: c, c: c,
CategoryTree: categoryTree, SubforumTree: subforumTree,
LineageBuilder: lineageBuilder, LineageBuilder: lineageBuilder,
} }
if cats, hasCats := c.PathParams["cats"]; hasCats { if subforums, hasSubforums := c.PathParams["subforums"]; hasSubforums {
catId, valid := validateSubforums(lineageBuilder, c.CurrentProject, cats) sfId, valid := validateSubforums(lineageBuilder, c.CurrentProject, subforums)
if !valid { if !valid {
return commonForumData{}, false 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 { if threadIdStr, hasThreadId := c.PathParams["threadid"]; hasThreadId {
@ -1117,10 +1116,10 @@ func getCommonForumData(c *RequestContext) (commonForumData, bool) {
FROM handmade_thread FROM handmade_thread
WHERE WHERE
id = $1 id = $1
AND category_id = $2 AND subforum_id = $2
`, `,
res.ThreadID, res.ThreadID,
res.CatID, res.SubforumID,
) )
c.Perf.EndBlock() c.Perf.EndBlock()
if err != nil { if err != nil {
@ -1146,11 +1145,9 @@ func getCommonForumData(c *RequestContext) (commonForumData, bool) {
WHERE WHERE
id = $1 id = $1
AND thread_id = $2 AND thread_id = $2
AND category_id = $3
`, `,
res.PostID, res.PostID,
res.ThreadID, res.ThreadID,
res.CatID,
) )
c.Perf.EndBlock() c.Perf.EndBlock()
if err != nil { if err != nil {
@ -1180,14 +1177,14 @@ func (cd *commonForumData) FetchThread(ctx context.Context, connOrTx db.ConnOrTx
SELECT $columns SELECT $columns
FROM FROM
handmade_thread AS thread 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 WHERE
thread.id = $1 thread.id = $1
AND NOT thread.deleted AND NOT thread.deleted
AND cat.id = $2 AND sf.id = $2
`, `,
cd.ThreadID, 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() cd.c.Perf.EndBlock()
if err != nil { 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 author ON post.author_id = author.id
LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id
WHERE WHERE
post.category_id = $1 post.thread_id = $1
AND post.thread_id = $2 AND post.id = $2
AND post.id = $3
AND NOT post.deleted AND NOT post.deleted
`, `,
cd.CatID,
cd.ThreadID, cd.ThreadID,
cd.PostID, cd.PostID,
) )
@ -1274,27 +1269,27 @@ func (cd *commonForumData) UserCanEditPost(ctx context.Context, connOrTx db.Conn
return result.AuthorID != nil && *result.AuthorID == user.ID 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 { if project.ForumID == nil {
return -1, false return -1, false
} }
subforumCatId := *project.ForumID subforumId := *project.ForumID
if len(catPath) == 0 { if len(sfPath) == 0 {
return subforumCatId, true return subforumId, true
} }
catPath = strings.ToLower(catPath) sfPath = strings.ToLower(sfPath)
valid := false valid := false
catSlugs := strings.Split(catPath, "/") sfSlugs := strings.Split(sfPath, "/")
lastSlug := catSlugs[len(catSlugs)-1] lastSlug := sfSlugs[len(sfSlugs)-1]
if len(lastSlug) > 0 { if len(lastSlug) > 0 {
lastSlugCatId := lineageBuilder.FindIdBySlug(project.ID, lastSlug) lastSlugSfId := lineageBuilder.FindIdBySlug(project.ID, lastSlug)
if lastSlugCatId != -1 { if lastSlugSfId != -1 {
subforumSlugs := lineageBuilder.GetSubforumLineageSlugs(lastSlugCatId) subforumSlugs := lineageBuilder.GetSubforumLineageSlugs(lastSlugSfId)
allMatch := true allMatch := true
for i, subforum := range subforumSlugs { for i, subforum := range subforumSlugs {
if subforum != catSlugs[i] { if subforum != sfSlugs[i] {
allMatch = false allMatch = false
break break
} }
@ -1302,8 +1297,8 @@ func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *m
valid = allMatch valid = allMatch
} }
if valid { 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() allProjects := iterProjects.ToSlice()
c.Perf.EndBlock() c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch category tree") c.Perf.StartBlock("SQL", "Fetch subforum tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock() c.Perf.EndBlock()
var currentUserId *int var currentUserId *int
@ -90,12 +90,11 @@ func Index(c *RequestContext) ResponseData {
c.Perf.StartBlock("SQL", fmt.Sprintf("Fetch posts for %s", proj.Name)) c.Perf.StartBlock("SQL", fmt.Sprintf("Fetch posts for %s", proj.Name))
type projectPostQuery struct { type projectPostQuery struct {
Post models.Post `db:"post"` Post models.Post `db:"post"`
Thread models.Thread `db:"thread"` Thread models.Thread `db:"thread"`
User models.User `db:"auth_user"` User models.User `db:"auth_user"`
LibraryResource *models.LibraryResource `db:"lib_resource"` ThreadLastReadTime *time.Time `db:"tlri.lastread"`
ThreadLastReadTime *time.Time `db:"tlri.lastread"` SubforumLastReadTime *time.Time `db:"slri.lastread"`
CatLastReadTime *time.Time `db:"clri.lastread"`
} }
projectPostIter, err := db.Query(c.Context(), c.Conn, projectPostQuery{}, projectPostIter, err := db.Query(c.Context(), c.Conn, projectPostQuery{},
` `
@ -107,22 +106,21 @@ func Index(c *RequestContext) ResponseData {
tlri.thread_id = post.thread_id tlri.thread_id = post.thread_id
AND tlri.user_id = $1 AND tlri.user_id = $1
) )
LEFT JOIN handmade_categorylastreadinfo AS clri ON ( LEFT JOIN handmade_subforumlastreadinfo AS slri ON (
clri.category_id = post.category_id slri.subforum_id = thread.subforum_id
AND clri.user_id = $1 AND slri.user_id = $1
) )
LEFT JOIN auth_user ON post.author_id = auth_user.id 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 WHERE
post.project_id = $2 post.project_id = $2
AND post.category_kind IN ($3, $4, $5, $6) AND post.thread_type = ANY ($3)
AND post.deleted = FALSE AND post.deleted = FALSE
ORDER BY postdate DESC ORDER BY postdate DESC
LIMIT $7 LIMIT $4
`, `,
currentUserId, currentUserId,
proj.ID, proj.ID,
models.CatKindBlog, models.CatKindForum, models.CatKindWiki, models.CatKindLibraryResource, []models.ThreadType{models.ThreadTypeProjectArticle, models.ThreadTypeForumPost},
maxPosts, maxPosts,
) )
c.Perf.EndBlock() c.Perf.EndBlock()
@ -134,7 +132,7 @@ func Index(c *RequestContext) ResponseData {
forumsUrl := "" forumsUrl := ""
if proj.ForumID != nil { 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 { } else {
c.Logger.Error().Int("ProjectID", proj.ID).Str("ProjectName", proj.Name).Msg("Project fetched by landing page but it doesn't have forums") 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 hasRead := false
if projectPost.ThreadLastReadTime != nil && projectPost.ThreadLastReadTime.After(projectPost.Post.PostDate) { if projectPost.ThreadLastReadTime != nil && projectPost.ThreadLastReadTime.After(projectPost.Post.PostDate) {
hasRead = true 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 hasRead = true
} }
featurable := (!proj.IsHMN() && featurable := (!proj.IsHMN() &&
projectPost.Post.CategoryKind == models.CatKindBlog && projectPost.Post.ThreadType == models.ThreadTypeProjectArticle &&
projectPost.Post.ParentID == nil && projectPost.Post.ParentID == nil &&
landingPageProject.FeaturedPost == nil) landingPageProject.FeaturedPost == nil)
@ -197,7 +195,6 @@ func Index(c *RequestContext) ResponseData {
&projectPost.Thread, &projectPost.Thread,
&projectPost.Post, &projectPost.Post,
&projectPost.User, &projectPost.User,
projectPost.LibraryResource,
!hasRead, !hasRead,
false, false,
c.Theme, c.Theme,
@ -263,14 +260,14 @@ func Index(c *RequestContext) ResponseData {
JOIN handmade_postversion AS ver ON post.current_id = ver.id JOIN handmade_postversion AS ver ON post.current_id = ver.id
WHERE WHERE
post.project_id = $1 post.project_id = $1
AND post.category_kind = $2 AND thread.type = $2
AND post.id = thread.first_id AND post.id = thread.first_id
AND NOT thread.deleted AND NOT thread.deleted
ORDER BY post.postdate DESC ORDER BY post.postdate DESC
LIMIT 1 LIMIT 1
`, `,
models.HMNProjectID, models.HMNProjectID,
models.CatKindBlog, models.ThreadTypeProjectArticle,
) )
if err != nil { if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch news post")) 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_asset AS asset ON asset.id = snippet.asset_id
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
ORDER BY snippet.when DESC ORDER BY snippet.when DESC
LIMIT 20
`, `,
) )
if err != nil { 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. // 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(thread *models.Thread, post *models.Post, lineageBuilder *models.SubforumLineageBuilder, projectSlug string) string {
func UrlForGenericPost(post *models.Post, subforums []string, threadTitle string, libraryResourceId int, projectSlug string) string { switch post.ThreadType {
switch post.CategoryKind { case models.ThreadTypeProjectArticle:
case models.CatKindBlog:
return hmnurl.BuildBlogPost(projectSlug, post.ThreadID, post.ID) return hmnurl.BuildBlogPost(projectSlug, post.ThreadID, post.ID)
case models.CatKindForum: case models.ThreadTypeForumPost:
return hmnurl.BuildForumPost(projectSlug, subforums, post.ThreadID, post.ID) return hmnurl.BuildForumPost(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), 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)
} }
return hmnurl.BuildProjectHomepage(projectSlug) return hmnurl.BuildProjectHomepage(projectSlug)
} }
var PostTypeMap = map[models.CategoryKind][]templates.PostType{ var PostTypeMap = map[models.ThreadType][]templates.PostType{
models.CatKindBlog: []templates.PostType{templates.PostTypeBlogPost, templates.PostTypeBlogComment}, models.ThreadTypeProjectArticle: {templates.PostTypeBlogPost, templates.PostTypeBlogComment},
models.CatKindForum: []templates.PostType{templates.PostTypeForumThread, templates.PostTypeForumReply}, models.ThreadTypeForumPost: {templates.PostTypeForumThread, templates.PostTypeForumReply},
models.CatKindWiki: []templates.PostType{templates.PostTypeWikiCreate, templates.PostTypeWikiTalk},
models.CatKindLibraryResource: []templates.PostType{templates.PostTypeLibraryComment, templates.PostTypeLibraryComment},
} }
var PostTypePrefix = map[templates.PostType]string{ var PostTypePrefix = map[templates.PostType]string{
templates.PostTypeBlogPost: "New blog post", templates.PostTypeBlogPost: "New blog post",
templates.PostTypeBlogComment: "Blog comment", templates.PostTypeBlogComment: "Blog comment",
templates.PostTypeForumThread: "New forum thread", templates.PostTypeForumThread: "New forum thread",
templates.PostTypeForumReply: "Forum reply", templates.PostTypeForumReply: "Forum reply",
templates.PostTypeWikiCreate: "New wiki page",
templates.PostTypeWikiTalk: "Wiki comment",
templates.PostTypeWikiEdit: "Wiki edit",
templates.PostTypeLibraryComment: "Library comment",
} }
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 var result []templates.Breadcrumb
result = append(result, templates.Breadcrumb{ result = append(result, templates.Breadcrumb{
Name: project.Name, Name: project.Name,
Url: hmnurl.BuildProjectHomepage(project.Slug), Url: hmnurl.BuildProjectHomepage(project.Slug),
}) })
result = append(result, templates.Breadcrumb{ result = append(result, templates.Breadcrumb{
Name: CategoryKindDisplayNames[post.CategoryKind], Name: ThreadTypeDisplayNames[thread.Type],
Url: BuildProjectMainCategoryUrl(project.Slug, post.CategoryKind), Url: BuildProjectRootResourceUrl(project.Slug, thread.Type),
}) })
switch post.CategoryKind { switch thread.Type {
case models.CatKindForum: case models.ThreadTypeForumPost:
subforums := lineageBuilder.GetSubforumLineage(post.CategoryID) subforums := lineageBuilder.GetSubforumLineage(*thread.SubforumID)
slugs := lineageBuilder.GetSubforumLineageSlugs(post.CategoryID) slugs := lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID)
for i, subforum := range subforums { for i, subforum := range subforums {
result = append(result, templates.Breadcrumb{ result = append(result, templates.Breadcrumb{
Name: *subforum.Name, // NOTE(asaf): All subforum categories must have names. Name: subforum.Name,
Url: hmnurl.BuildForumCategory(project.Slug, slugs[0:i+1], 1), 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 return result
} }
// NOTE(asaf): THIS DOESN'T HANDLE WIKI EDIT ITEMS. Wiki edits are PostTextVersions, not Posts. func MakePostListItem(
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 { 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 var result templates.PostListItem
result.Title = thread.Title result.Title = thread.Title
result.User = templates.UserToTemplate(user, currentTheme) result.User = templates.UserToTemplate(user, currentTheme)
result.Date = post.PostDate result.Date = post.PostDate
result.Unread = unread result.Unread = unread
libraryResourceId := 0 result.Url = UrlForGenericPost(thread, post, lineageBuilder, project.Slug)
if libraryResource != nil {
libraryResourceId = libraryResource.ID
}
result.Url = UrlForGenericPost(post, lineageBuilder.GetSubforumLineageSlugs(post.CategoryID), thread.Title, libraryResourceId, project.Slug)
result.Preview = post.Preview result.Preview = post.Preview
postType := templates.PostTypeUnknown postType := templates.PostTypeUnknown
postTypeOptions, found := PostTypeMap[post.CategoryKind] postTypeOptions, found := PostTypeMap[post.ThreadType]
if found { if found {
var hasParent int var hasParent int
if post.ParentID != nil { if post.ParentID != nil {
@ -104,7 +86,7 @@ func MakePostListItem(lineageBuilder *models.CategoryLineageBuilder, project *mo
result.PostTypePrefix = PostTypePrefix[result.PostType] result.PostTypePrefix = PostTypePrefix[result.PostType]
if includeBreadcrumbs { if includeBreadcrumbs {
result.Breadcrumbs = PostBreadcrumbs(lineageBuilder, project, post, libraryResource) result.Breadcrumbs = PostBreadcrumbs(lineageBuilder, project, thread)
} }
return result return result

View File

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

View File

@ -156,8 +156,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
mainRoutes.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread)) mainRoutes.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
mainRoutes.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit))) mainRoutes.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit)))
mainRoutes.GET(hmnurl.RegexForumThread, ForumThread) mainRoutes.GET(hmnurl.RegexForumThread, ForumThread)
mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory) mainRoutes.GET(hmnurl.RegexForum, Forum)
mainRoutes.POST(hmnurl.RegexForumCategoryMarkRead, authMiddleware(csrfMiddleware(ForumCategoryMarkRead))) mainRoutes.POST(hmnurl.RegexForumMarkRead, authMiddleware(csrfMiddleware(ForumMarkRead)))
mainRoutes.GET(hmnurl.RegexForumPost, ForumPostRedirect) mainRoutes.GET(hmnurl.RegexForumPost, ForumPostRedirect)
mainRoutes.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply)) mainRoutes.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply))
mainRoutes.POST(hmnurl.RegexForumPostReply, authMiddleware(csrfMiddleware(ForumPostReplySubmit))) mainRoutes.POST(hmnurl.RegexForumPostReply, authMiddleware(csrfMiddleware(ForumPostReplySubmit)))
@ -216,8 +216,7 @@ func getBaseData(c *RequestContext) templates.BaseData {
ProjectHomepageUrl: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug), ProjectHomepageUrl: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
ProjectIndexUrl: hmnurl.BuildProjectIndex(1), ProjectIndexUrl: hmnurl.BuildProjectIndex(1),
BlogUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, 1), BlogUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, 1),
ForumsUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, nil, 1), ForumsUrl: hmnurl.BuildForum(c.CurrentProject.Slug, nil, 1),
WikiUrl: hmnurl.BuildWiki(c.CurrentProject.Slug),
LibraryUrl: hmnurl.BuildLibrary(c.CurrentProject.Slug), LibraryUrl: hmnurl.BuildLibrary(c.CurrentProject.Slug),
ManifestoUrl: hmnurl.BuildManifesto(), ManifestoUrl: hmnurl.BuildManifesto(),
EpisodeGuideUrl: hmnurl.BuildHomepage(), // TODO(asaf) EpisodeGuideUrl: hmnurl.BuildHomepage(), // TODO(asaf)
@ -231,7 +230,7 @@ func getBaseData(c *RequestContext) templates.BaseData {
CodeOfConductUrl: hmnurl.BuildCodeOfConduct(), CodeOfConductUrl: hmnurl.BuildCodeOfConduct(),
CommunicationGuidelinesUrl: hmnurl.BuildCommunicationGuidelines(), CommunicationGuidelinesUrl: hmnurl.BuildCommunicationGuidelines(),
ProjectIndexUrl: hmnurl.BuildProjectIndex(1), ProjectIndexUrl: hmnurl.BuildProjectIndex(1),
ForumsUrl: hmnurl.BuildForumCategory(models.HMNProjectSlug, nil, 1), ForumsUrl: hmnurl.BuildForum(models.HMNProjectSlug, nil, 1),
ContactUrl: hmnurl.BuildContactPage(), ContactUrl: hmnurl.BuildContactPage(),
SitemapUrl: hmnurl.BuildSiteMap(), 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" "git.handmade.network/hmn/hmn/src/templates"
) )
var TimelineTypeMap = map[models.CategoryKind][]templates.TimelineType{ var TimelineTypeMap = map[models.ThreadType][]templates.TimelineType{
// { No parent , Has parent } // { No parent , Has parent }
models.CatKindBlog: {templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment}, models.ThreadTypeProjectArticle: {templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment},
models.CatKindForum: {templates.TimelineTypeForumThread, templates.TimelineTypeForumReply}, models.ThreadTypeForumPost: {templates.TimelineTypeForumThread, templates.TimelineTypeForumReply},
models.CatKindWiki: {templates.TimelineTypeWikiCreate, templates.TimelineTypeWikiTalk},
models.CatKindLibraryResource: {templates.TimelineTypeLibraryComment, templates.TimelineTypeLibraryComment},
} }
var TimelineItemClassMap = map[templates.TimelineType]string{ var TimelineItemClassMap = map[templates.TimelineType]string{
@ -27,12 +25,6 @@ var TimelineItemClassMap = map[templates.TimelineType]string{
templates.TimelineTypeBlogPost: "blogs", templates.TimelineTypeBlogPost: "blogs",
templates.TimelineTypeBlogComment: "blogs", templates.TimelineTypeBlogComment: "blogs",
templates.TimelineTypeWikiCreate: "wiki",
templates.TimelineTypeWikiEdit: "wiki",
templates.TimelineTypeWikiTalk: "wiki",
templates.TimelineTypeLibraryComment: "library",
templates.TimelineTypeSnippetImage: "snippets", templates.TimelineTypeSnippetImage: "snippets",
templates.TimelineTypeSnippetVideo: "snippets", templates.TimelineTypeSnippetVideo: "snippets",
templates.TimelineTypeSnippetAudio: "snippets", templates.TimelineTypeSnippetAudio: "snippets",
@ -48,21 +40,15 @@ var TimelineTypeTitleMap = map[templates.TimelineType]string{
templates.TimelineTypeBlogPost: "New blog post", templates.TimelineTypeBlogPost: "New blog post",
templates.TimelineTypeBlogComment: "Blog comment", 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.TimelineTypeSnippetImage: "Snippet",
templates.TimelineTypeSnippetVideo: "Snippet", templates.TimelineTypeSnippetVideo: "Snippet",
templates.TimelineTypeSnippetAudio: "Snippet", templates.TimelineTypeSnippetAudio: "Snippet",
templates.TimelineTypeSnippetYoutube: "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 itemType := templates.TimelineTypeUnknown
typeByCatKind, found := TimelineTypeMap[post.CategoryKind] typeByCatKind, found := TimelineTypeMap[post.ThreadType]
if found { if found {
hasParent := 0 hasParent := 0
if post.ParentID != nil { if post.ParentID != nil {
@ -71,17 +57,12 @@ func PostToTimelineItem(lineageBuilder *models.CategoryLineageBuilder, post *mod
itemType = typeByCatKind[hasParent] itemType = typeByCatKind[hasParent]
} }
libraryResourceId := 0
if libraryResource != nil {
libraryResourceId = libraryResource.ID
}
return templates.TimelineItem{ return templates.TimelineItem{
Type: itemType, Type: itemType,
TypeTitle: TimelineTypeTitleMap[itemType], TypeTitle: TimelineTypeTitleMap[itemType],
Class: TimelineItemClassMap[itemType], Class: TimelineItemClassMap[itemType],
Date: post.PostDate, 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), OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
OwnerName: templates.UserDisplayName(owner), OwnerName: templates.UserDisplayName(owner),
@ -89,25 +70,7 @@ func PostToTimelineItem(lineageBuilder *models.CategoryLineageBuilder, post *mod
Description: "", // NOTE(asaf): No description for posts Description: "", // NOTE(asaf): No description for posts
Title: thread.Title, Title: thread.Title,
Breadcrumbs: PostBreadcrumbs(lineageBuilder, project, post, libraryResource), Breadcrumbs: PostBreadcrumbs(lineageBuilder, project, thread),
}
}
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),
} }
} }

View File

@ -5,16 +5,12 @@ import (
"git.handmade.network/hmn/hmn/src/models" "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 { switch kind {
case models.CatKindBlog: case models.ThreadTypeProjectArticle:
return hmnurl.BuildBlog(projectSlug, 1) return hmnurl.BuildBlog(projectSlug, 1)
case models.CatKindForum: case models.ThreadTypeForumPost:
return hmnurl.BuildForumCategory(projectSlug, nil, 1) return hmnurl.BuildForum(projectSlug, nil, 1)
case models.CatKindWiki:
return hmnurl.BuildWiki(projectSlug)
case models.CatKindLibraryResource:
return hmnurl.BuildLibrary(projectSlug)
} }
return hmnurl.BuildProjectHomepage(projectSlug) return hmnurl.BuildProjectHomepage(projectSlug)
} }

View File

@ -20,8 +20,6 @@ type UserProfileTemplateData struct {
TimelineItems []templates.TimelineItem TimelineItems []templates.TimelineItem
NumForums int NumForums int
NumBlogs int NumBlogs int
NumWiki int
NumLibrary int
NumSnippets int NumSnippets int
} }
@ -115,10 +113,9 @@ func UserProfile(c *RequestContext) ResponseData {
c.Perf.EndBlock() c.Perf.EndBlock()
type postQuery struct { type postQuery struct {
Post models.Post `db:"post"` Post models.Post `db:"post"`
Thread models.Thread `db:"thread"` Thread models.Thread `db:"thread"`
LibraryResource *models.LibraryResource `db:"lib_resource"` Project models.Project `db:"project"`
Project models.Project `db:"project"`
} }
c.Perf.StartBlock("SQL", "Fetch posts") c.Perf.StartBlock("SQL", "Fetch posts")
postQueryResult, err := db.Query(c.Context(), c.Conn, postQuery{}, postQueryResult, err := db.Query(c.Context(), c.Conn, postQuery{},
@ -128,7 +125,6 @@ func UserProfile(c *RequestContext) ResponseData {
handmade_post AS post handmade_post AS post
INNER JOIN handmade_thread AS thread ON thread.id = post.thread_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 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 WHERE
post.author_id = $1 post.author_id = $1
AND project.lifecycle = ANY ($2) AND project.lifecycle = ANY ($2)
@ -142,37 +138,6 @@ func UserProfile(c *RequestContext) ResponseData {
postQuerySlice := postQueryResult.ToSlice() postQuerySlice := postQueryResult.ToSlice()
c.Perf.EndBlock() 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 { type snippetQuery struct {
Snippet models.Snippet `db:"snippet"` Snippet models.Snippet `db:"snippet"`
Asset *models.Asset `db:"asset"` Asset *models.Asset `db:"asset"`
@ -197,17 +162,15 @@ func UserProfile(c *RequestContext) ResponseData {
snippetQuerySlice := snippetQueryResult.ToSlice() snippetQuerySlice := snippetQueryResult.ToSlice()
c.Perf.EndBlock() c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch category tree") c.Perf.StartBlock("SQL", "Fetch subforum tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock() c.Perf.EndBlock()
c.Perf.StartBlock("PROFILE", "Construct timeline items") 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 numForums := 0
numBlogs := 0 numBlogs := 0
numWiki := len(wikiEditQuerySlice)
numLibrary := 0
numSnippets := len(snippetQuerySlice) numSnippets := len(snippetQuerySlice)
for _, postRow := range postQuerySlice { for _, postRow := range postQuerySlice {
@ -217,7 +180,6 @@ func UserProfile(c *RequestContext) ResponseData {
&postData.Post, &postData.Post,
&postData.Thread, &postData.Thread,
&postData.Project, &postData.Project,
postData.LibraryResource,
profileUser, profileUser,
c.Theme, c.Theme,
) )
@ -226,10 +188,6 @@ func UserProfile(c *RequestContext) ResponseData {
numForums += 1 numForums += 1
case templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment: case templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment:
numBlogs += 1 numBlogs += 1
case templates.TimelineTypeWikiCreate, templates.TimelineTypeWikiTalk:
numWiki += 1
case templates.TimelineTypeLibraryComment:
numLibrary += 1
} }
if timelineItem.Type != templates.TimelineTypeUnknown { if timelineItem.Type != templates.TimelineTypeUnknown {
timelineItems = append(timelineItems, timelineItem) 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 { for _, snippetRow := range snippetQuerySlice {
snippetData := snippetRow.(*snippetQuery) snippetData := snippetRow.(*snippetQuery)
timelineItem := SnippetToTimelineItem( timelineItem := SnippetToTimelineItem(
@ -282,8 +226,6 @@ func UserProfile(c *RequestContext) ResponseData {
TimelineItems: timelineItems, TimelineItems: timelineItems,
NumForums: numForums, NumForums: numForums,
NumBlogs: numBlogs, NumBlogs: numBlogs,
NumWiki: numWiki,
NumLibrary: numLibrary,
NumSnippets: numSnippets, NumSnippets: numSnippets,
}, c.Perf) }, c.Perf)
return res return res