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:
		
							parent
							
								
									15ff1de6fc
								
							
						
					
					
						commit
						8ecb4a7173
					
				| 
						 | 
				
			
			@ -4,14 +4,10 @@ const TimelineTypes = {
 | 
			
		|||
    FORUM_REPLY: 2,
 | 
			
		||||
    BLOG_POST: 3,
 | 
			
		||||
    BLOG_COMMENT: 4,
 | 
			
		||||
    WIKI_CREATE: 5,
 | 
			
		||||
    WIKI_EDIT: 6,
 | 
			
		||||
    WIKI_TALK: 7,
 | 
			
		||||
    LIBRARY_COMMENT: 8,
 | 
			
		||||
    SNIPPET_IMAGE: 9,
 | 
			
		||||
    SNIPPET_VIDEO: 10,
 | 
			
		||||
    SNIPPET_AUDIO: 11,
 | 
			
		||||
    SNIPPET_YOUTUBE: 12
 | 
			
		||||
    SNIPPET_IMAGE: 5,
 | 
			
		||||
    SNIPPET_VIDEO: 6,
 | 
			
		||||
    SNIPPET_AUDIO: 7,
 | 
			
		||||
    SNIPPET_YOUTUBE: 8
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const showcaseItemTemplate = makeTemplateCloner("showcase_item");
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8634,11 +8634,6 @@ input[type=submit] {
 | 
			
		|||
    padding-left: 10px;
 | 
			
		||||
    max-width: 80em; }
 | 
			
		||||
 | 
			
		||||
.wiki .post {
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  margin: auto;
 | 
			
		||||
  max-width: 70em; }
 | 
			
		||||
 | 
			
		||||
.post .contents h1, .post .contents h2 {
 | 
			
		||||
  margin: 20px 0px; }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -8750,56 +8745,11 @@ input[type=submit] {
 | 
			
		|||
.blog .post-list .post:nth-child(even) {
 | 
			
		||||
  background-color: transparent; }
 | 
			
		||||
 | 
			
		||||
.wiki .post p {
 | 
			
		||||
  margin: 10px 0px; }
 | 
			
		||||
 | 
			
		||||
.wiki .toc {
 | 
			
		||||
  border-color: #aaa;
 | 
			
		||||
  border-color: var(--wiki-border-color);
 | 
			
		||||
  border-left-width: 1px; }
 | 
			
		||||
  .wiki .toc .toc-number {
 | 
			
		||||
    color: #333;
 | 
			
		||||
    color: var(--wiki-toc-number-color); }
 | 
			
		||||
  .wiki .toc ul {
 | 
			
		||||
    list-style-type: none;
 | 
			
		||||
    margin-left: 10px;
 | 
			
		||||
    margin-bottom: 5px; }
 | 
			
		||||
  .wiki .toc li {
 | 
			
		||||
    margin-left: 0px; }
 | 
			
		||||
 | 
			
		||||
.wiki .aside {
 | 
			
		||||
  border-color: #aaa;
 | 
			
		||||
  border-color: var(--wiki-border-color);
 | 
			
		||||
  border-left-width: 1px;
 | 
			
		||||
  margin-left: 20px; }
 | 
			
		||||
  .wiki .aside::before {
 | 
			
		||||
    margin-left: -20px;
 | 
			
		||||
    margin-right: 15px;
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    width: 10px;
 | 
			
		||||
    content: "\21b4 "; }
 | 
			
		||||
  .wiki .aside .aside-heading {
 | 
			
		||||
    padding: 2px;
 | 
			
		||||
    margin: 1px;
 | 
			
		||||
    border-radius: 3px;
 | 
			
		||||
    border-width: 0px;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    display: inline;
 | 
			
		||||
    background-color: transparent; }
 | 
			
		||||
  .wiki .aside > .aside-body {
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    padding-left: 10px; }
 | 
			
		||||
  .wiki .aside.folded::before {
 | 
			
		||||
    content: "\2192 "; }
 | 
			
		||||
  .wiki .aside.folded > .aside-body {
 | 
			
		||||
    max-height: 0px; }
 | 
			
		||||
 | 
			
		||||
.featured-post .meta .avatar-icon {
 | 
			
		||||
  left: -60px;
 | 
			
		||||
  bottom: -5px; }
 | 
			
		||||
 | 
			
		||||
.blog .body blockquote,
 | 
			
		||||
.wiki .body blockquote {
 | 
			
		||||
.blog .body blockquote {
 | 
			
		||||
  padding-top: 1px;
 | 
			
		||||
  padding-bottom: 1px; }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -9446,9 +9396,6 @@ span.icon-rss::before {
 | 
			
		|||
.timeline.no-blogs .blogs {
 | 
			
		||||
  display: none; }
 | 
			
		||||
 | 
			
		||||
.timeline.no-wiki .wiki {
 | 
			
		||||
  display: none; }
 | 
			
		||||
 | 
			
		||||
.timeline.no-library .library {
 | 
			
		||||
  display: none; }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -300,8 +300,6 @@ will throw an error.
 | 
			
		|||
  --irc-users-popout-background: #181818;
 | 
			
		||||
  --irc-users-popout-border-color-left: #444;
 | 
			
		||||
  --irc-users-popout-border-color-right: #333;
 | 
			
		||||
  --wiki-border-color: #444;
 | 
			
		||||
  --wiki-toc-number-color: #bbb;
 | 
			
		||||
  --code-line-number-color: #444;
 | 
			
		||||
  --library-star-btn-background: #252525;
 | 
			
		||||
  --library-star-btn-border-color: #bbb;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -318,8 +318,6 @@ will throw an error.
 | 
			
		|||
  --irc-users-popout-background: #fff;
 | 
			
		||||
  --irc-users-popout-border-color-left: #bbb;
 | 
			
		||||
  --irc-users-popout-border-color-right: #ccc;
 | 
			
		||||
  --wiki-border-color: #aaa;
 | 
			
		||||
  --wiki-toc-number-color: #333;
 | 
			
		||||
  --code-line-number-color: #777;
 | 
			
		||||
  --library-star-btn-background: #fff;
 | 
			
		||||
  --library-star-btn-border-color: #999;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -22,7 +22,7 @@ import (
 | 
			
		|||
Values of these kinds are ok to query even if they are not directly understood by pgtype.
 | 
			
		||||
This is common for custom types like:
 | 
			
		||||
 | 
			
		||||
	type CategoryKind int
 | 
			
		||||
	type ThreadType int
 | 
			
		||||
*/
 | 
			
		||||
var queryableKinds = []reflect.Kind{
 | 
			
		||||
	reflect.Int,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -145,22 +145,22 @@ func TestPodcastRSS(t *testing.T) {
 | 
			
		|||
	AssertRegexMatch(t, BuildPodcastRSS(""), RegexPodcastRSS, nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestForumCategory(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildForumCategory("", nil, 1), RegexForumCategory, nil)
 | 
			
		||||
	AssertRegexMatch(t, BuildForumCategory("", []string{"wip"}, 2), RegexForumCategory, map[string]string{"cats": "wip", "page": "2"})
 | 
			
		||||
	AssertRegexMatch(t, BuildForumCategory("", []string{"sub", "wip"}, 2), RegexForumCategory, map[string]string{"cats": "sub/wip", "page": "2"})
 | 
			
		||||
	AssertSubdomain(t, BuildForumCategory("hmn", nil, 1), "")
 | 
			
		||||
	AssertSubdomain(t, BuildForumCategory("", nil, 1), "")
 | 
			
		||||
	AssertSubdomain(t, BuildForumCategory("hero", nil, 1), "hero")
 | 
			
		||||
	assert.Panics(t, func() { BuildForumCategory("", nil, 0) })
 | 
			
		||||
	assert.Panics(t, func() { BuildForumCategory("", []string{"", "wip"}, 1) })
 | 
			
		||||
	assert.Panics(t, func() { BuildForumCategory("", []string{" ", "wip"}, 1) })
 | 
			
		||||
	assert.Panics(t, func() { BuildForumCategory("", []string{"wip/jobs"}, 1) })
 | 
			
		||||
func TestForum(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildForum("", nil, 1), RegexForum, nil)
 | 
			
		||||
	AssertRegexMatch(t, BuildForum("", []string{"wip"}, 2), RegexForum, map[string]string{"subforums": "wip", "page": "2"})
 | 
			
		||||
	AssertRegexMatch(t, BuildForum("", []string{"sub", "wip"}, 2), RegexForum, map[string]string{"subforums": "sub/wip", "page": "2"})
 | 
			
		||||
	AssertSubdomain(t, BuildForum("hmn", nil, 1), "")
 | 
			
		||||
	AssertSubdomain(t, BuildForum("", nil, 1), "")
 | 
			
		||||
	AssertSubdomain(t, BuildForum("hero", nil, 1), "hero")
 | 
			
		||||
	assert.Panics(t, func() { BuildForum("", nil, 0) })
 | 
			
		||||
	assert.Panics(t, func() { BuildForum("", []string{"", "wip"}, 1) })
 | 
			
		||||
	assert.Panics(t, func() { BuildForum("", []string{" ", "wip"}, 1) })
 | 
			
		||||
	assert.Panics(t, func() { BuildForum("", []string{"wip/jobs"}, 1) })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestForumNewThread(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}, false), RegexForumNewThread, map[string]string{"cats": "sub/wip"})
 | 
			
		||||
	AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}, true), RegexForumNewThreadSubmit, map[string]string{"cats": "sub/wip"})
 | 
			
		||||
	AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}, false), RegexForumNewThread, map[string]string{"subforums": "sub/wip"})
 | 
			
		||||
	AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}, true), RegexForumNewThreadSubmit, map[string]string{"subforums": "sub/wip"})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestForumThread(t *testing.T) {
 | 
			
		||||
| 
						 | 
				
			
			@ -197,12 +197,6 @@ func TestForumPostReply(t *testing.T) {
 | 
			
		|||
	AssertSubdomain(t, BuildForumPostReply("hero", nil, 1, 2), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestForumPostQuote(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildForumPostQuote("", nil, 1, 2), RegexForumPostQuote, map[string]string{"threadid": "1", "postid": "2"})
 | 
			
		||||
	AssertRegexNoMatch(t, BuildForumPostQuote("", nil, 1, 2), RegexForumPost)
 | 
			
		||||
	AssertSubdomain(t, BuildForumPostQuote("hero", nil, 1, 2), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestBlog(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildBlog("", 1), RegexBlog, nil)
 | 
			
		||||
	AssertRegexMatch(t, BuildBlog("", 2), RegexBlog, map[string]string{"page": "2"})
 | 
			
		||||
| 
						 | 
				
			
			@ -248,82 +242,6 @@ func TestBlogPostQuote(t *testing.T) {
 | 
			
		|||
	AssertSubdomain(t, BuildBlogPostQuote("hero", 1, 2), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestWiki(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildWiki(""), RegexWiki, nil)
 | 
			
		||||
	AssertSubdomain(t, BuildWiki("hero"), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestWikiIndex(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiIndex(""), RegexWikiIndex, nil)
 | 
			
		||||
	AssertSubdomain(t, BuildWikiIndex("hero"), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestWikiArticle(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiArticle("", 1, ""), RegexWikiArticle, map[string]string{"articleid": "1"})
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiArticle("", 1, "wiki/title/--"), RegexWikiArticle, map[string]string{"articleid": "1"})
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiArticleWithSectionName("", 1, "wiki/title/--", "Hello world"), RegexWikiArticle, map[string]string{"articleid": "1"})
 | 
			
		||||
	AssertSubdomain(t, BuildWikiArticle("hero", 1, ""), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestWikiArticleEdit(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiArticleEdit("", 1), RegexWikiArticleEdit, map[string]string{"articleid": "1"})
 | 
			
		||||
	AssertSubdomain(t, BuildWikiArticleEdit("hero", 1), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestWikiArticleDelete(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiArticleDelete("", 1), RegexWikiArticleDelete, map[string]string{"articleid": "1"})
 | 
			
		||||
	AssertSubdomain(t, BuildWikiArticleDelete("hero", 1), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestWikiArticleHistory(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiArticleHistory("", 1, ""), RegexWikiArticleHistory, map[string]string{"articleid": "1"})
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiArticleHistory("", 1, "wiki/title/--"), RegexWikiArticleHistory, map[string]string{"articleid": "1"})
 | 
			
		||||
	AssertSubdomain(t, BuildWikiArticleHistory("hero", 1, ""), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestWikiTalk(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiTalk("", 1, ""), RegexWikiTalk, map[string]string{"articleid": "1"})
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiTalk("", 1, "wiki/title/--"), RegexWikiTalk, map[string]string{"articleid": "1"})
 | 
			
		||||
	AssertSubdomain(t, BuildWikiTalk("hero", 1, ""), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestWikiRevision(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiRevision("", 1, "", 2), RegexWikiRevision, map[string]string{"articleid": "1", "revisionid": "2"})
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiRevision("", 1, "wiki/title/--", 2), RegexWikiRevision, map[string]string{"articleid": "1", "revisionid": "2"})
 | 
			
		||||
	AssertSubdomain(t, BuildWikiRevision("hero", 1, "", 2), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestWikiDiff(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiDiff("", 1, "", 2, 3), RegexWikiDiff, map[string]string{"articleid": "1", "revisionidold": "2", "revisionidnew": "3"})
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiDiff("", 1, "wiki/title", 2, 3), RegexWikiDiff, map[string]string{"articleid": "1", "revisionidold": "2", "revisionidnew": "3"})
 | 
			
		||||
	AssertSubdomain(t, BuildWikiDiff("hero", 1, "wiki/title", 2, 3), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestWikiTalkPost(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiTalkPost("", 1, 2), RegexWikiTalkPost, map[string]string{"articleid": "1", "postid": "2"})
 | 
			
		||||
	AssertSubdomain(t, BuildWikiTalkPost("hero", 1, 2), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestWikiTalkPostDelete(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiTalkPostDelete("", 1, 2), RegexWikiTalkPostDelete, map[string]string{"articleid": "1", "postid": "2"})
 | 
			
		||||
	AssertSubdomain(t, BuildWikiTalkPostDelete("hero", 1, 2), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestWikiTalkPostEdit(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiTalkPostEdit("", 1, 2), RegexWikiTalkPostEdit, map[string]string{"articleid": "1", "postid": "2"})
 | 
			
		||||
	AssertSubdomain(t, BuildWikiTalkPostEdit("hero", 1, 2), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestWikiTalkPostReply(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiTalkPostReply("", 1, 2), RegexWikiTalkPostReply, map[string]string{"articleid": "1", "postid": "2"})
 | 
			
		||||
	AssertSubdomain(t, BuildWikiTalkPostReply("hero", 1, 2), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestWikiTalkPostQuote(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildWikiTalkPostQuote("", 1, 2), RegexWikiTalkPostQuote, map[string]string{"articleid": "1", "postid": "2"})
 | 
			
		||||
	AssertSubdomain(t, BuildWikiTalkPostQuote("hero", 1, 2), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestLibrary(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildLibrary(""), RegexLibrary, nil)
 | 
			
		||||
	AssertSubdomain(t, BuildLibrary("hero"), "hero")
 | 
			
		||||
| 
						 | 
				
			
			@ -344,38 +262,6 @@ func TestLibraryResource(t *testing.T) {
 | 
			
		|||
	AssertSubdomain(t, BuildLibraryResource("hero", 1), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestLibraryDiscussion(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildLibraryDiscussion("", 1, 2, 1), RegexLibraryDiscussion, map[string]string{"resourceid": "1", "threadid": "2"})
 | 
			
		||||
	AssertRegexMatch(t, BuildLibraryDiscussion("", 1, 2, 3), RegexLibraryDiscussion, map[string]string{"resourceid": "1", "threadid": "2", "page": "3"})
 | 
			
		||||
	AssertRegexMatch(t, BuildLibraryDiscussionWithPostHash("", 1, 2, 3, 123), RegexLibraryDiscussion, map[string]string{"resourceid": "1", "threadid": "2", "page": "3"})
 | 
			
		||||
	AssertSubdomain(t, BuildLibraryDiscussion("hero", 1, 2, 3), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestLibraryPost(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildLibraryPost("", 1, 2, 3), RegexLibraryPost, map[string]string{"resourceid": "1", "threadid": "2", "postid": "3"})
 | 
			
		||||
	AssertSubdomain(t, BuildLibraryPost("hero", 1, 2, 3), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestLibraryPostDelete(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildLibraryPostDelete("", 1, 2, 3), RegexLibraryPostDelete, map[string]string{"resourceid": "1", "threadid": "2", "postid": "3"})
 | 
			
		||||
	AssertSubdomain(t, BuildLibraryPostDelete("hero", 1, 2, 3), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestLibraryPostEdit(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildLibraryPostEdit("", 1, 2, 3), RegexLibraryPostEdit, map[string]string{"resourceid": "1", "threadid": "2", "postid": "3"})
 | 
			
		||||
	AssertSubdomain(t, BuildLibraryPostEdit("hero", 1, 2, 3), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestLibraryPostReply(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildLibraryPostReply("", 1, 2, 3), RegexLibraryPostReply, map[string]string{"resourceid": "1", "threadid": "2", "postid": "3"})
 | 
			
		||||
	AssertSubdomain(t, BuildLibraryPostReply("hero", 1, 2, 3), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestLibraryPostQuote(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildLibraryPostQuote("", 1, 2, 3), RegexLibraryPostQuote, map[string]string{"resourceid": "1", "threadid": "2", "postid": "3"})
 | 
			
		||||
	AssertSubdomain(t, BuildLibraryPostQuote("hero", 1, 2, 3), "hero")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestProjectCSS(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -395,8 +281,8 @@ func TestPublic(t *testing.T) {
 | 
			
		|||
	AssertRegexMatch(t, BuildUserFile("mylogo.png"), RegexPublic, nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestForumCategoryMarkRead(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildForumCategoryMarkRead(5), RegexForumCategoryMarkRead, map[string]string{"catid": "5"})
 | 
			
		||||
func TestForumMarkRead(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildForumMarkRead(5), RegexForumMarkRead, map[string]string{"sfid": "5"})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func AssertSubdomain(t *testing.T, fullUrl string, expectedSubdomain string) {
 | 
			
		||||
| 
						 | 
				
			
			@ -468,3 +354,9 @@ func AssertRegexNoMatch(t *testing.T, fullUrl string, regex *regexp.Regexp) {
 | 
			
		|||
	match := regex.FindStringSubmatch(requestPath)
 | 
			
		||||
	assert.Nilf(t, match, "Url matched regex: [%s] vs [%s]", requestPath, regex.String())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestThingsThatDontNeedCoverage(t *testing.T) {
 | 
			
		||||
	// look the other way ಠ_ಠ
 | 
			
		||||
	BuildPodcastEpisodeFile("foo", "bar")
 | 
			
		||||
	BuildS3Asset("ha ha")
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -305,17 +305,17 @@ func BuildPodcastEpisodeFile(projectSlug string, filename string) string {
 | 
			
		|||
* Forums
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
// TODO(asaf): This also matches urls generated by BuildForumThread (/t/ is identified as a cat, and the threadid as a page)
 | 
			
		||||
// This shouldn't be a problem since we will match Thread before Category in the router, but should we enforce it here?
 | 
			
		||||
var RegexForumCategory = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?(/(?P<page>\d+))?$`)
 | 
			
		||||
// TODO(asaf): This also matches urls generated by BuildForumThread (/t/ is identified as a subforum, and the threadid as a page)
 | 
			
		||||
// This shouldn't be a problem since we will match Thread before Subforum in the router, but should we enforce it here?
 | 
			
		||||
var RegexForum = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?(/(?P<page>\d+))?$`)
 | 
			
		||||
 | 
			
		||||
func BuildForumCategory(projectSlug string, subforums []string, page int) string {
 | 
			
		||||
func BuildForum(projectSlug string, subforums []string, page int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	if page < 1 {
 | 
			
		||||
		panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	builder := buildForumCategoryPath(subforums)
 | 
			
		||||
	builder := buildSubforumPath(subforums)
 | 
			
		||||
 | 
			
		||||
	if page > 1 {
 | 
			
		||||
		builder.WriteRune('/')
 | 
			
		||||
| 
						 | 
				
			
			@ -325,12 +325,12 @@ func BuildForumCategory(projectSlug string, subforums []string, page int) string
 | 
			
		|||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexForumNewThread = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/new$`)
 | 
			
		||||
var RegexForumNewThreadSubmit = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/new/submit$`)
 | 
			
		||||
var RegexForumNewThread = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/new$`)
 | 
			
		||||
var RegexForumNewThreadSubmit = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/new/submit$`)
 | 
			
		||||
 | 
			
		||||
func BuildForumNewThread(projectSlug string, subforums []string, submit bool) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildForumCategoryPath(subforums)
 | 
			
		||||
	builder := buildSubforumPath(subforums)
 | 
			
		||||
	builder.WriteString("/t/new")
 | 
			
		||||
	if submit {
 | 
			
		||||
		builder.WriteString("/submit")
 | 
			
		||||
| 
						 | 
				
			
			@ -339,7 +339,7 @@ func BuildForumNewThread(projectSlug string, subforums []string, submit bool) st
 | 
			
		|||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexForumThread = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)(-([^/]+))?(/(?P<page>\d+))?$`)
 | 
			
		||||
var RegexForumThread = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)(-([^/]+))?(/(?P<page>\d+))?$`)
 | 
			
		||||
 | 
			
		||||
func BuildForumThread(projectSlug string, subforums []string, threadId int, title string, page int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
| 
						 | 
				
			
			@ -355,7 +355,7 @@ func BuildForumThreadWithPostHash(projectSlug string, subforums []string, thread
 | 
			
		|||
	return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexForumPost = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)$`)
 | 
			
		||||
var RegexForumPost = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)$`)
 | 
			
		||||
 | 
			
		||||
func BuildForumPost(projectSlug string, subforums []string, threadId int, postId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
| 
						 | 
				
			
			@ -364,7 +364,7 @@ func BuildForumPost(projectSlug string, subforums []string, threadId int, postId
 | 
			
		|||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexForumPostDelete = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/delete$`)
 | 
			
		||||
var RegexForumPostDelete = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/delete$`)
 | 
			
		||||
 | 
			
		||||
func BuildForumPostDelete(projectSlug string, subforums []string, threadId int, postId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
| 
						 | 
				
			
			@ -373,7 +373,7 @@ func BuildForumPostDelete(projectSlug string, subforums []string, threadId int,
 | 
			
		|||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexForumPostEdit = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/edit$`)
 | 
			
		||||
var RegexForumPostEdit = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/edit$`)
 | 
			
		||||
 | 
			
		||||
func BuildForumPostEdit(projectSlug string, subforums []string, threadId int, postId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
| 
						 | 
				
			
			@ -382,7 +382,7 @@ func BuildForumPostEdit(projectSlug string, subforums []string, threadId int, po
 | 
			
		|||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexForumPostReply = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/reply$`)
 | 
			
		||||
var RegexForumPostReply = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/reply$`)
 | 
			
		||||
 | 
			
		||||
// TODO: It's kinda weird that we have "replies" to a specific post. That's not how the data works. I guess this just affects what you see as the "post you're replying to" on the post composer page?
 | 
			
		||||
func BuildForumPostReply(projectSlug string, subforums []string, threadId int, postId int) string {
 | 
			
		||||
| 
						 | 
				
			
			@ -472,163 +472,6 @@ func BuildBlogPostQuote(projectSlug string, threadId int, postId int) string {
 | 
			
		|||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
* Wiki
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
var RegexWiki = regexp.MustCompile(`^/wiki$`)
 | 
			
		||||
 | 
			
		||||
func BuildWiki(projectSlug string) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	return ProjectUrl("/wiki", nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexWikiIndex = regexp.MustCompile(`^/wiki/index$`)
 | 
			
		||||
 | 
			
		||||
func BuildWikiIndex(projectSlug string) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	return ProjectUrl("/wiki/index", nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexWikiArticle = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)(-([^/])+)?$`)
 | 
			
		||||
 | 
			
		||||
func BuildWikiArticle(projectSlug string, articleId int, title string) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildWikiArticlePath(articleId, title)
 | 
			
		||||
 | 
			
		||||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func BuildWikiArticleWithSectionName(projectSlug string, articleId int, title string, sectionName string) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildWikiArticlePath(articleId, title)
 | 
			
		||||
 | 
			
		||||
	return ProjectUrlWithFragment(builder.String(), nil, projectSlug, sectionName)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexWikiArticleEdit = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)/edit$`)
 | 
			
		||||
 | 
			
		||||
func BuildWikiArticleEdit(projectSlug string, articleId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildWikiArticlePath(articleId, "")
 | 
			
		||||
	builder.WriteString("/edit")
 | 
			
		||||
 | 
			
		||||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexWikiArticleDelete = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)/delete$`)
 | 
			
		||||
 | 
			
		||||
func BuildWikiArticleDelete(projectSlug string, articleId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildWikiArticlePath(articleId, "")
 | 
			
		||||
	builder.WriteString("/delete")
 | 
			
		||||
 | 
			
		||||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexWikiArticleHistory = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)(-([^/])+)?/history$`)
 | 
			
		||||
 | 
			
		||||
func BuildWikiArticleHistory(projectSlug string, articleId int, title string) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildWikiArticlePath(articleId, title)
 | 
			
		||||
	builder.WriteString("/history")
 | 
			
		||||
 | 
			
		||||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexWikiTalk = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)(-([^/])+)?/talk$`)
 | 
			
		||||
 | 
			
		||||
func BuildWikiTalk(projectSlug string, articleId int, title string) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildWikiArticlePath(articleId, title)
 | 
			
		||||
	builder.WriteString("/talk")
 | 
			
		||||
 | 
			
		||||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexWikiRevision = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)(-([^/])+)?/(?P<revisionid>\d+)$`)
 | 
			
		||||
 | 
			
		||||
func BuildWikiRevision(projectSlug string, articleId int, title string, revisionId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	if revisionId < 1 {
 | 
			
		||||
		panic(oops.New(nil, "Invalid wiki revision id (%d), must be >= 1", revisionId))
 | 
			
		||||
	}
 | 
			
		||||
	builder := buildWikiArticlePath(articleId, title)
 | 
			
		||||
	builder.WriteRune('/')
 | 
			
		||||
	builder.WriteString(strconv.Itoa(revisionId))
 | 
			
		||||
 | 
			
		||||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexWikiDiff = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)(-([^/])+)?/diff/(?P<revisionidold>\d+)/(?P<revisionidnew>\d+)$`)
 | 
			
		||||
 | 
			
		||||
func BuildWikiDiff(projectSlug string, articleId int, title string, revisionIdOld int, revisionIdNew int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	if revisionIdOld < 1 {
 | 
			
		||||
		panic(oops.New(nil, "Invalid wiki revision id (%d), must be >= 1", revisionIdOld))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if revisionIdNew < 1 {
 | 
			
		||||
		panic(oops.New(nil, "Invalid wiki revision id (%d), must be >= 1", revisionIdNew))
 | 
			
		||||
	}
 | 
			
		||||
	builder := buildWikiArticlePath(articleId, title)
 | 
			
		||||
	builder.WriteString("/diff/")
 | 
			
		||||
	builder.WriteString(strconv.Itoa(revisionIdOld))
 | 
			
		||||
	builder.WriteRune('/')
 | 
			
		||||
	builder.WriteString(strconv.Itoa(revisionIdNew))
 | 
			
		||||
 | 
			
		||||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexWikiTalkPost = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)/talk/(?P<postid>\d+)$`)
 | 
			
		||||
 | 
			
		||||
func BuildWikiTalkPost(projectSlug string, articleId int, postId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildWikiTalkPath(articleId, postId)
 | 
			
		||||
 | 
			
		||||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexWikiTalkPostDelete = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)/talk/(?P<postid>\d+)/delete$`)
 | 
			
		||||
 | 
			
		||||
func BuildWikiTalkPostDelete(projectSlug string, articleId int, postId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildWikiTalkPath(articleId, postId)
 | 
			
		||||
	builder.WriteString("/delete")
 | 
			
		||||
 | 
			
		||||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexWikiTalkPostEdit = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)/talk/(?P<postid>\d+)/edit$`)
 | 
			
		||||
 | 
			
		||||
func BuildWikiTalkPostEdit(projectSlug string, articleId int, postId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildWikiTalkPath(articleId, postId)
 | 
			
		||||
	builder.WriteString("/edit")
 | 
			
		||||
 | 
			
		||||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexWikiTalkPostReply = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)/talk/(?P<postid>\d+)/reply$`)
 | 
			
		||||
 | 
			
		||||
func BuildWikiTalkPostReply(projectSlug string, articleId int, postId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildWikiTalkPath(articleId, postId)
 | 
			
		||||
	builder.WriteString("/reply")
 | 
			
		||||
 | 
			
		||||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexWikiTalkPostQuote = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)/talk/(?P<postid>\d+)/quote$`)
 | 
			
		||||
 | 
			
		||||
func BuildWikiTalkPostQuote(projectSlug string, articleId int, postId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildWikiTalkPath(articleId, postId)
 | 
			
		||||
	builder.WriteString("/quote")
 | 
			
		||||
 | 
			
		||||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
* Library
 | 
			
		||||
 */
 | 
			
		||||
| 
						 | 
				
			
			@ -671,74 +514,6 @@ func BuildLibraryResource(projectSlug string, resourceId int) string {
 | 
			
		|||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexLibraryDiscussion = regexp.MustCompile(`^/library/resource/(?P<resourceid>\d+)/d/(?P<threadid>\d+)(/(?P<page>\d+))?$`)
 | 
			
		||||
 | 
			
		||||
func BuildLibraryDiscussion(projectSlug string, resourceId int, threadId int, page int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildLibraryDiscussionPath(resourceId, threadId, page)
 | 
			
		||||
 | 
			
		||||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func BuildLibraryDiscussionWithPostHash(projectSlug string, resourceId int, threadId int, page int, postId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	if postId < 1 {
 | 
			
		||||
		panic(oops.New(nil, "Invalid library post ID (%d), must be >= 1", postId))
 | 
			
		||||
	}
 | 
			
		||||
	builder := buildLibraryDiscussionPath(resourceId, threadId, page)
 | 
			
		||||
 | 
			
		||||
	return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexLibraryPost = regexp.MustCompile(`^/library/resource/(?P<resourceid>\d+)/d/(?P<threadid>\d+)/p/(?P<postid>\d+)$`)
 | 
			
		||||
 | 
			
		||||
func BuildLibraryPost(projectSlug string, resourceId int, threadId int, postId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildLibraryPostPath(resourceId, threadId, postId)
 | 
			
		||||
 | 
			
		||||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexLibraryPostDelete = regexp.MustCompile(`^/library/resource/(?P<resourceid>\d+)/d/(?P<threadid>\d+)/p/(?P<postid>\d+)/delete$`)
 | 
			
		||||
 | 
			
		||||
func BuildLibraryPostDelete(projectSlug string, resourceId int, threadId int, postId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildLibraryPostPath(resourceId, threadId, postId)
 | 
			
		||||
	builder.WriteString("/delete")
 | 
			
		||||
 | 
			
		||||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexLibraryPostEdit = regexp.MustCompile(`^/library/resource/(?P<resourceid>\d+)/d/(?P<threadid>\d+)/p/(?P<postid>\d+)/edit$`)
 | 
			
		||||
 | 
			
		||||
func BuildLibraryPostEdit(projectSlug string, resourceId int, threadId int, postId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildLibraryPostPath(resourceId, threadId, postId)
 | 
			
		||||
	builder.WriteString("/edit")
 | 
			
		||||
 | 
			
		||||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexLibraryPostReply = regexp.MustCompile(`^/library/resource/(?P<resourceid>\d+)/d/(?P<threadid>\d+)/p/(?P<postid>\d+)/reply$`)
 | 
			
		||||
 | 
			
		||||
func BuildLibraryPostReply(projectSlug string, resourceId int, threadId int, postId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildLibraryPostPath(resourceId, threadId, postId)
 | 
			
		||||
	builder.WriteString("/reply")
 | 
			
		||||
 | 
			
		||||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexLibraryPostQuote = regexp.MustCompile(`^/library/resource/(?P<resourceid>\d+)/d/(?P<threadid>\d+)/p/(?P<postid>\d+)/quote$`)
 | 
			
		||||
 | 
			
		||||
func BuildLibraryPostQuote(projectSlug string, resourceId int, threadId int, postId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	builder := buildLibraryPostPath(resourceId, threadId, postId)
 | 
			
		||||
	builder.WriteString("/quote")
 | 
			
		||||
 | 
			
		||||
	return ProjectUrl(builder.String(), nil, projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
* Assets
 | 
			
		||||
 */
 | 
			
		||||
| 
						 | 
				
			
			@ -803,18 +578,18 @@ func BuildUserFile(filepath string) string {
 | 
			
		|||
* Other
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
var RegexForumCategoryMarkRead = regexp.MustCompile(`^/markread/(?P<catid>\d+)$`)
 | 
			
		||||
var RegexForumMarkRead = regexp.MustCompile(`^/markread/(?P<sfid>\d+)$`)
 | 
			
		||||
 | 
			
		||||
// NOTE(asaf): categoryId == 0 means ALL CATEGORIES
 | 
			
		||||
func BuildForumCategoryMarkRead(categoryId int) string {
 | 
			
		||||
// NOTE(asaf): subforumId == 0 means ALL SUBFORUMS
 | 
			
		||||
func BuildForumMarkRead(subforumId int) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	if categoryId < 0 {
 | 
			
		||||
		panic(oops.New(nil, "Invalid category ID (%d), must be >= 0", categoryId))
 | 
			
		||||
	if subforumId < 0 {
 | 
			
		||||
		panic(oops.New(nil, "Invalid subforum ID (%d), must be >= 0", subforumId))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var builder strings.Builder
 | 
			
		||||
	builder.WriteString("/markread/")
 | 
			
		||||
	builder.WriteString(strconv.Itoa(categoryId))
 | 
			
		||||
	builder.WriteString(strconv.Itoa(subforumId))
 | 
			
		||||
 | 
			
		||||
	return Url(builder.String(), nil)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -825,7 +600,7 @@ var RegexCatchAll = regexp.MustCompile("")
 | 
			
		|||
* Helper functions
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
func buildForumCategoryPath(subforums []string) *strings.Builder {
 | 
			
		||||
func buildSubforumPath(subforums []string) *strings.Builder {
 | 
			
		||||
	for _, subforum := range subforums {
 | 
			
		||||
		if strings.Contains(subforum, "/") {
 | 
			
		||||
			panic(oops.New(nil, "Tried building forum url with / in subforum name"))
 | 
			
		||||
| 
						 | 
				
			
			@ -855,7 +630,7 @@ func buildForumThreadPath(subforums []string, threadId int, title string, page i
 | 
			
		|||
		panic(oops.New(nil, "Invalid forum thread ID (%d), must be >= 1", threadId))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	builder := buildForumCategoryPath(subforums)
 | 
			
		||||
	builder := buildSubforumPath(subforums)
 | 
			
		||||
 | 
			
		||||
	builder.WriteString("/t/")
 | 
			
		||||
	builder.WriteString(strconv.Itoa(threadId))
 | 
			
		||||
| 
						 | 
				
			
			@ -880,7 +655,7 @@ func buildForumPostPath(subforums []string, threadId int, postId int) *strings.B
 | 
			
		|||
		panic(oops.New(nil, "Invalid forum post ID (%d), must be >= 1", postId))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	builder := buildForumCategoryPath(subforums)
 | 
			
		||||
	builder := buildSubforumPath(subforums)
 | 
			
		||||
 | 
			
		||||
	builder.WriteString("/t/")
 | 
			
		||||
	builder.WriteString(strconv.Itoa(threadId))
 | 
			
		||||
| 
						 | 
				
			
			@ -934,34 +709,6 @@ func buildBlogPostPath(threadId int, postId int) *strings.Builder {
 | 
			
		|||
	return &builder
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func buildWikiArticlePath(articleId int, title string) *strings.Builder {
 | 
			
		||||
	if articleId < 1 {
 | 
			
		||||
		panic(oops.New(nil, "Invalid wiki article ID (%d), must be >= 1", articleId))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var builder strings.Builder
 | 
			
		||||
 | 
			
		||||
	builder.WriteString("/wiki/")
 | 
			
		||||
	builder.WriteString(strconv.Itoa(articleId))
 | 
			
		||||
	if len(title) > 0 {
 | 
			
		||||
		builder.WriteRune('-')
 | 
			
		||||
		builder.WriteString(PathSafeTitle(title))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return &builder
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func buildWikiTalkPath(articleId int, postId int) *strings.Builder {
 | 
			
		||||
	if postId < 1 {
 | 
			
		||||
		panic(oops.New(nil, "Invalid wiki post ID (%d), must be >= 1", postId))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	builder := buildWikiArticlePath(articleId, "")
 | 
			
		||||
	builder.WriteString("/talk/")
 | 
			
		||||
	builder.WriteString(strconv.Itoa(postId))
 | 
			
		||||
	return builder
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func buildLibraryResourcePath(resourceId int) *strings.Builder {
 | 
			
		||||
	if resourceId < 1 {
 | 
			
		||||
		panic(oops.New(nil, "Invalid library resource ID (%d), must be >= 1", resourceId))
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,6 @@ import (
 | 
			
		|||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/migration/types"
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/oops"
 | 
			
		||||
	"github.com/jackc/pgx/v4"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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")
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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")
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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")
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,9 +1,26 @@
 | 
			
		|||
package migrations
 | 
			
		||||
 | 
			
		||||
import "git.handmade.network/hmn/hmn/src/migration/types"
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/migration/types"
 | 
			
		||||
	"github.com/jackc/pgx/v4"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var All map[types.MigrationVersion]types.Migration = make(map[types.MigrationVersion]types.Migration)
 | 
			
		||||
 | 
			
		||||
func registerMigration(m types.Migration) {
 | 
			
		||||
	All[m.Version()] = m
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func debugQuery(ctx context.Context, tx pgx.Tx, sql string) {
 | 
			
		||||
	rows, err := tx.Query(ctx, sql)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		panic(err)
 | 
			
		||||
	}
 | 
			
		||||
	for rows.Next() {
 | 
			
		||||
		vals, _ := rows.Values()
 | 
			
		||||
		fmt.Println(vals)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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] = ¤t.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
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -3,7 +3,6 @@ package models
 | 
			
		|||
type LibraryResource struct {
 | 
			
		||||
	ID int `db:"id"`
 | 
			
		||||
 | 
			
		||||
	CategoryID int  `db:"category_id"`
 | 
			
		||||
	ProjectID *int `db:"project_id"`
 | 
			
		||||
 | 
			
		||||
	Name          string `db:"name"`
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,13 +10,12 @@ type Post struct {
 | 
			
		|||
 | 
			
		||||
	// TODO: Document each of these
 | 
			
		||||
	AuthorID  *int `db:"author_id"`
 | 
			
		||||
	CategoryID int  `db:"category_id"`
 | 
			
		||||
	ParentID  *int `db:"parent_id"`
 | 
			
		||||
	ThreadID  int  `db:"thread_id"`
 | 
			
		||||
	CurrentID int  `db:"current_id"`
 | 
			
		||||
	ProjectID int  `db:"project_id"`
 | 
			
		||||
 | 
			
		||||
	CategoryKind CategoryKind `db:"category_kind"`
 | 
			
		||||
	ThreadType ThreadType `db:"thread_type"`
 | 
			
		||||
 | 
			
		||||
	PostDate time.Time `db:"postdate"`
 | 
			
		||||
	Deleted  bool      `db:"deleted"`
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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] = ¤t.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
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,9 +1,24 @@
 | 
			
		|||
package models
 | 
			
		||||
 | 
			
		||||
type ThreadType int
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	ThreadTypeProjectArticle ThreadType = iota + 1
 | 
			
		||||
	ThreadTypeForumPost
 | 
			
		||||
	_ // formerly occupied by static pages, RIP
 | 
			
		||||
	_ // formerly occupied by who the hell knows what, RIP
 | 
			
		||||
	_ // formerly occupied by the wiki, RIP
 | 
			
		||||
	_ // formerly occupied by library discussions, RIP
 | 
			
		||||
	ThreadTypePersonalArticle
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type Thread struct {
 | 
			
		||||
	ID int `db:"id"`
 | 
			
		||||
 | 
			
		||||
	CategoryID int `db:"category_id"`
 | 
			
		||||
	Type                  ThreadType `db:"type"`
 | 
			
		||||
	ProjectID             int        `db:"project_id"`
 | 
			
		||||
	SubforumID            *int       `db:"subforum_id"`
 | 
			
		||||
	PersonalArticleUserID *int       `db:"personal_article_user_id"`
 | 
			
		||||
 | 
			
		||||
	Title   string `db:"title"`
 | 
			
		||||
	Sticky  bool   `db:"sticky"`
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,3 +1,5 @@
 | 
			
		|||
// +build js
 | 
			
		||||
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -157,13 +157,6 @@
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
.post {
 | 
			
		||||
  .wiki &,
 | 
			
		||||
  {
 | 
			
		||||
    padding: 0;
 | 
			
		||||
    margin: auto;
 | 
			
		||||
    max-width: 70em;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .contents {
 | 
			
		||||
    h1, h2 {
 | 
			
		||||
      margin: 20px 0px;
 | 
			
		||||
| 
						 | 
				
			
			@ -332,79 +325,12 @@
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.wiki {
 | 
			
		||||
  .post p {
 | 
			
		||||
    margin: 10px 0px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .toc {
 | 
			
		||||
    @include usevar(border-color, 'wiki-border-color');
 | 
			
		||||
 | 
			
		||||
    border-left-width: 1px;
 | 
			
		||||
 | 
			
		||||
    .toc-number {
 | 
			
		||||
      @include usevar(color, 'wiki-toc-number-color');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    ul {
 | 
			
		||||
      list-style-type: none;
 | 
			
		||||
      margin-left: 10px;
 | 
			
		||||
      margin-bottom: 5px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    li {
 | 
			
		||||
      margin-left: 0px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .aside {
 | 
			
		||||
    @include usevar(border-color, 'wiki-border-color');
 | 
			
		||||
 | 
			
		||||
    border-left-width: 1px;
 | 
			
		||||
    margin-left:20px;
 | 
			
		||||
 | 
			
		||||
    &::before {
 | 
			
		||||
      margin-left:-20px;
 | 
			
		||||
      margin-right:15px;
 | 
			
		||||
      display:inline-block;
 | 
			
		||||
      width:10px;
 | 
			
		||||
      content:"\21b4 ";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .aside-heading {
 | 
			
		||||
      padding:2px;
 | 
			
		||||
      margin:1px;
 | 
			
		||||
      border-radius:3px;
 | 
			
		||||
      border-width:0px;
 | 
			
		||||
      cursor:pointer;
 | 
			
		||||
      display:inline;
 | 
			
		||||
      background-color:transparent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    > .aside-body {
 | 
			
		||||
      overflow:hidden;
 | 
			
		||||
      padding-left:10px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.folded {
 | 
			
		||||
      &::before {
 | 
			
		||||
        content:"\2192 ";
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      > .aside-body {
 | 
			
		||||
        max-height: 0px;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.featured-post .meta .avatar-icon {
 | 
			
		||||
  left:-60px;
 | 
			
		||||
  bottom:-5px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.blog .body blockquote,
 | 
			
		||||
.wiki .body blockquote {
 | 
			
		||||
.blog .body blockquote {
 | 
			
		||||
    padding-top:1px;
 | 
			
		||||
    padding-bottom:1px;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,9 +5,6 @@
 | 
			
		|||
    &.no-blogs .blogs {
 | 
			
		||||
        display: none;
 | 
			
		||||
    }
 | 
			
		||||
    &.no-wiki .wiki {
 | 
			
		||||
        display: none;
 | 
			
		||||
    }
 | 
			
		||||
    &.no-library .library {
 | 
			
		||||
        display: none;
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -112,9 +112,6 @@ $vars: (
 | 
			
		|||
  irc-users-popout-border-color-left: #444,
 | 
			
		||||
  irc-users-popout-border-color-right: #333,
 | 
			
		||||
 | 
			
		||||
  wiki-border-color: #444,
 | 
			
		||||
  wiki-toc-number-color: #bbb,
 | 
			
		||||
 | 
			
		||||
  code-line-number-color: #444,
 | 
			
		||||
 | 
			
		||||
  library-star-btn-background: #252525,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -112,9 +112,6 @@ $vars: (
 | 
			
		|||
  irc-users-popout-border-color-left: #bbb,
 | 
			
		||||
  irc-users-popout-border-color-right: #ccc,
 | 
			
		||||
 | 
			
		||||
  wiki-border-color: #aaa,
 | 
			
		||||
  wiki-toc-number-color: #333,
 | 
			
		||||
 | 
			
		||||
  code-line-number-color: #777,
 | 
			
		||||
 | 
			
		||||
  library-star-btn-background: #fff,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -99,7 +99,6 @@ func ProjectToTemplate(p *models.Project, theme string) Project {
 | 
			
		|||
 | 
			
		||||
		HasBlog:    true, // TODO: Check flag sets or whatever
 | 
			
		||||
		HasForum:   true,
 | 
			
		||||
		HasWiki:    true,
 | 
			
		||||
		HasLibrary: true,
 | 
			
		||||
 | 
			
		||||
		DateApproved: p.DateApproved,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@
 | 
			
		|||
 | 
			
		||||
{{ define "content" }}
 | 
			
		||||
<div class="content-block">
 | 
			
		||||
    {{ range .Subcategories }}
 | 
			
		||||
    {{ range .Subforums }}
 | 
			
		||||
        <div class="pv3">
 | 
			
		||||
            <h2 class="ma0 ph3 pb2">
 | 
			
		||||
                <a href="{{ .Url }}">
 | 
			
		||||
| 
						 | 
				
			
			@ -21,18 +21,18 @@
 | 
			
		|||
        </div>
 | 
			
		||||
    {{ end }}
 | 
			
		||||
    <div class="optionbar">
 | 
			
		||||
        {{ template "forum_category_options" . }}
 | 
			
		||||
        {{ template "subforum_options" . }}
 | 
			
		||||
    </div>
 | 
			
		||||
    {{ range .Threads }}
 | 
			
		||||
        {{ template "thread_list_item.html" . }}
 | 
			
		||||
    {{ end }}
 | 
			
		||||
    <div class="optionbar bottom">
 | 
			
		||||
        {{ template "forum_category_options" . }}
 | 
			
		||||
        {{ template "subforum_options" . }}
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
{{ end }}
 | 
			
		||||
 | 
			
		||||
{{ define "forum_category_options" }}
 | 
			
		||||
{{ define "subforum_options" }}
 | 
			
		||||
<div class="options">
 | 
			
		||||
    {{ if .User }}
 | 
			
		||||
        <a class="button new-thread" href="{{ .NewThreadUrl }}"><span class="big pr1">+</span> New Thread</a>
 | 
			
		||||
| 
						 | 
				
			
			@ -4,7 +4,7 @@
 | 
			
		|||
<div class="content-block">
 | 
			
		||||
    <div class="optionbar">
 | 
			
		||||
        <div class="options">
 | 
			
		||||
            <a class="button" href="{{ .CategoryUrl }}">← Back to index</a>
 | 
			
		||||
            <a class="button" href="{{ .SubforumUrl }}">← Back to index</a>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="options">
 | 
			
		||||
            {{ template "pagination.html" .Pagination }}
 | 
			
		||||
| 
						 | 
				
			
			@ -118,7 +118,7 @@
 | 
			
		|||
    {{ end }}
 | 
			
		||||
    <div class="optionbar bottom">
 | 
			
		||||
        <div class="options order-1">
 | 
			
		||||
            <a class="button" href="{{ .CategoryUrl }}">← Back to index</a>
 | 
			
		||||
            <a class="button" href="{{ .SubforumUrl }}">← Back to index</a>
 | 
			
		||||
            {{ if .Thread.Locked }}
 | 
			
		||||
                <span>Thread is locked.</span>
 | 
			
		||||
            {{ else if .User }}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -51,9 +51,6 @@
 | 
			
		|||
                {{ if .Project.HasForum }}
 | 
			
		||||
                    <a href="{{ .Header.ForumsUrl }}" class="forums">Forums</a>
 | 
			
		||||
                {{ end }}
 | 
			
		||||
                {{ if .Project.HasWiki }}
 | 
			
		||||
                    <a href="{{ .Header.WikiUrl }}" class="wiki">Wiki</a>
 | 
			
		||||
                {{ end }}
 | 
			
		||||
                {{ if .Project.HasLibrary }}
 | 
			
		||||
                    <a href="{{ .Header.LibraryUrl }}" class="library">Library</a>
 | 
			
		||||
                {{ end }}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -172,166 +172,6 @@
 | 
			
		|||
</div>
 | 
			
		||||
{{ end }}
 | 
			
		||||
 | 
			
		||||
{{/* TODO(asaf): Delete this section once we're done with the landing page
 | 
			
		||||
{% block columns %}
 | 
			
		||||
{% include "showcase/js_templates.html" %}
 | 
			
		||||
{% include "timeline/js_templates.html" %}
 | 
			
		||||
<div class="content-block pb3">
 | 
			
		||||
    <div class="tc tl-l w-100 pb2">
 | 
			
		||||
        <h2 class="di-l mr2-l">Community Showcase</h2>
 | 
			
		||||
        <ul class="list dib-l">
 | 
			
		||||
            <li class="dib-ns ma0 ph2">
 | 
			
		||||
                <a href="{{ .ShowcaseUrl }}">View all</a>
 | 
			
		||||
            </li>
 | 
			
		||||
        </ul>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="showcase relative overflow-hidden">
 | 
			
		||||
        <div id="showcase-items" class="flex relative pl3 pl0-ns"></div>
 | 
			
		||||
        <div class="arrow-container left">
 | 
			
		||||
            <a href="javascript:void(0)" class="arrow svgicon svgicon-nofix" onclick="scrollShowcase('left')">{% svg 'chevron-left' %}</a>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="arrow-container right">
 | 
			
		||||
            <a href="javascript:void(0)" class="arrow svgicon svgicon-nofix" onclick="scrollShowcase('right')">{% svg 'chevron-right' %}</a>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="c--dimmer i pv2 ph3 ph0-ns">
 | 
			
		||||
		This is a selection of recent work done by community members. Want to participate? <a href="{{ .DiscordUrl }}" target="_blank">Join us on Discord.</a>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
<script>
 | 
			
		||||
    const timelineData = JSON.parse("{{ showcase_timeline_json|escapejs }}");
 | 
			
		||||
 | 
			
		||||
    const showcaseEl = document.querySelector('#showcase-items');
 | 
			
		||||
    for (const item of timelineData.items) {
 | 
			
		||||
        const [itemEl, addThumbnail] = makeShowcaseItem(item);
 | 
			
		||||
        addThumbnail();
 | 
			
		||||
        itemEl.container.classList.add('mr3');
 | 
			
		||||
        showcaseEl.appendChild(itemEl.root);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function rem2px(rem) {
 | 
			
		||||
        return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function scrollShowcase(direction = null) {
 | 
			
		||||
        const ITEM_WIDTH = showcaseEl.querySelector('.showcase-item').getBoundingClientRect().width;
 | 
			
		||||
        const ITEM_SPACING = rem2px(1);
 | 
			
		||||
 | 
			
		||||
        const showcaseWidth = showcaseEl.getBoundingClientRect().width;
 | 
			
		||||
        const numVisible = showcaseWidth / (ITEM_WIDTH + ITEM_SPACING);
 | 
			
		||||
        const scrollMagnitude = Math.floor(numVisible) - 1;
 | 
			
		||||
        const scrollDirection = (direction === 'right' ? 1 : (direction === 'left' ? -1 : 0));
 | 
			
		||||
        const scrollAmount = scrollMagnitude * scrollDirection;
 | 
			
		||||
 | 
			
		||||
        const minIndex = 0;
 | 
			
		||||
        const maxIndex = timelineData.items.length - Math.floor(numVisible);
 | 
			
		||||
 | 
			
		||||
        const currentScrollIndex = parseInt(showcaseEl.getAttribute('data-scroll-index'), 10) || 0;
 | 
			
		||||
        const newScrollIndex = Math.max(minIndex, Math.min(maxIndex, currentScrollIndex + scrollAmount));
 | 
			
		||||
 | 
			
		||||
        showcaseEl.style.transform = `translateX(${-newScrollIndex * (ITEM_WIDTH + ITEM_SPACING)}px)`;
 | 
			
		||||
        showcaseEl.setAttribute('data-scroll-index', newScrollIndex);
 | 
			
		||||
 | 
			
		||||
        const leftArrowEl = document.querySelector('.arrow-container.left');
 | 
			
		||||
        const rightArrowEl = document.querySelector('.arrow-container.right');
 | 
			
		||||
 | 
			
		||||
        leftArrowEl.classList.toggle('hide', newScrollIndex === minIndex);
 | 
			
		||||
        rightArrowEl.classList.toggle('hide', newScrollIndex === maxIndex);
 | 
			
		||||
    }
 | 
			
		||||
    scrollShowcase(); // force a scroll as an easy way to initialize styles
 | 
			
		||||
 | 
			
		||||
    window.addEventListener('resize', () => scrollShowcase());
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<div class="content-block">
 | 
			
		||||
    <div class="optionbar pb2">
 | 
			
		||||
        <div class="tc tl-l w-100">
 | 
			
		||||
            <h2 class="di-l mr2-l">Around the Network</h2>
 | 
			
		||||
            <ul class="list dib-l">
 | 
			
		||||
                <li class="dib-ns ma0 ph2">
 | 
			
		||||
                    <a href="{% url 'feed' %}">View all posts on HMN</a>
 | 
			
		||||
                </li>
 | 
			
		||||
                <li class="dib-ns ma0 ph2">
 | 
			
		||||
                    <a href="{% url 'podcast' %}">Podcast</a>
 | 
			
		||||
                </li>
 | 
			
		||||
                <!-- <li class="dib-ns ma0 ph2">
 | 
			
		||||
                    <a href="{% url 'streams' %}">See who's live</a>
 | 
			
		||||
                </li> -->
 | 
			
		||||
                <li class="dib-ns ma0 ph2">
 | 
			
		||||
                    <a href="/blogs/p/1138-%5Btutorial%5D_handmade_network_irc" target="_blank">Chat in IRC</a>
 | 
			
		||||
                </li>
 | 
			
		||||
                <li class="dib-ns ma0 ph2">
 | 
			
		||||
                    <a href="https://discord.gg/hxWxDee" target="_blank">Chat on Discord</a>
 | 
			
		||||
                </li>
 | 
			
		||||
                <li class="dib-ns ma0 ph2">
 | 
			
		||||
                    <a href="https://handmadedev.show/" target="_blank">See the Show</a>
 | 
			
		||||
                </li>
 | 
			
		||||
            </ul>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
{% spaceless %}
 | 
			
		||||
    <div class="content-block news cf">
 | 
			
		||||
        {% for col in recent_post_columns %}
 | 
			
		||||
            <div class="fl w-100 w-50-l">
 | 
			
		||||
                <div class="mw7 mw-none-l center-layout">
 | 
			
		||||
                    {% if forloop.counter == 1 %}
 | 
			
		||||
                        <div class="pt3">
 | 
			
		||||
                            {% include "blog_index_thread_list_entry.html" with post=featured_post align_top=True %}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
 | 
			
		||||
                    {% for entry in col %}
 | 
			
		||||
                        {% with proj=entry.project posts=entry.posts %}
 | 
			
		||||
                            <div class="pt3" id="p{{proj.slug}}">
 | 
			
		||||
                                <a
 | 
			
		||||
                                    href="{% url 'cover_page' subdomain=proj.slug %}"
 | 
			
		||||
                                    {% if user|get_theme == 'dark' %}
 | 
			
		||||
                                        style="color:#{% rgb_accent proj.color_1 0.55 %};"
 | 
			
		||||
                                    {% else %}
 | 
			
		||||
                                        style="color:#{% rgb_accent proj.color_1 0.25 %};"
 | 
			
		||||
                                    {% endif %}
 | 
			
		||||
                                >
 | 
			
		||||
                                    <h2 class="ph3">{{ proj.name }}</h2>
 | 
			
		||||
                                </a>
 | 
			
		||||
 | 
			
		||||
                                {% if entry.featured and proj.slug != "hmn" %}
 | 
			
		||||
                                    {% with post=entry.featured.0 has_read=entry.featured.1 %} 
 | 
			
		||||
                                        {% if post.category.kind == 5 and post.parent == None %}
 | 
			
		||||
                                            {% include "thread_list_entry.html" with thread=post.thread %}
 | 
			
		||||
                                        {% else %}
 | 
			
		||||
                                            {% include "blog_index_thread_list_entry.html" with align_top=True %}
 | 
			
		||||
                                        {% endif %}
 | 
			
		||||
                                    {% endwith %}
 | 
			
		||||
                                {% endif %}
 | 
			
		||||
 | 
			
		||||
                                {% for post, has_read in posts %}
 | 
			
		||||
                                    {% if forloop.counter0 < max_posts %}
 | 
			
		||||
                                        {% include "thread_list_entry.html" with thread=post.thread %}
 | 
			
		||||
                                    {% endif %}
 | 
			
		||||
                                {% endfor %}
 | 
			
		||||
                                {% with more=posts|length|add:-5|clamp_lower:0 %}
 | 
			
		||||
                                    {% if more > 0 %}
 | 
			
		||||
                                        <div class="ph3 thread unread more">
 | 
			
		||||
                                            <a class="title" 
 | 
			
		||||
                                                href="{% url 'project_forum' subdomain=proj.slug %}"
 | 
			
		||||
                                            >{{ more }} more recently →</a>
 | 
			
		||||
                                        </div>
 | 
			
		||||
                                    {% endif %}
 | 
			
		||||
                                {% endwith %}
 | 
			
		||||
                            </div>
 | 
			
		||||
                        {% endwith %}
 | 
			
		||||
                    {% endfor %}
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
    </div>
 | 
			
		||||
{% endspaceless %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
*/}}
 | 
			
		||||
 | 
			
		||||
{{ define "landing_page_featured_post" }}
 | 
			
		||||
{{/* Call this template with a LandingPageFeaturedPost. */}}
 | 
			
		||||
<div class="flex items-start ph3 pv2 {{ if .Unread }}unread{{ else }}read{{ end }}">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -25,12 +25,6 @@
 | 
			
		|||
					{{ if gt .NumBlogs 0 }}
 | 
			
		||||
						<div class="dib filter blogs mr2"><input data-type="blogs" class="v-mid mr1" type="checkbox" id="timeline-checkbox-blogs" checked /><label class="v-mid" for="timeline-checkbox-blogs">Blogs (<span class="count">{{ .NumBlogs }}</span>)</label></div>
 | 
			
		||||
					{{ end }}
 | 
			
		||||
					{{ if gt .NumWiki 0 }}
 | 
			
		||||
						<div class="dib filter wiki mr2"><input data-type="wiki" class="v-mid mr1" type="checkbox" id="timeline-checkbox-wiki" checked /><label class="v-mid" for="timeline-checkbox-wiki">Wiki (<span class="count">{{ .NumWiki }}</span>)</label></div>
 | 
			
		||||
					{{ end }}
 | 
			
		||||
					{{ if gt .NumLibrary 0 }}
 | 
			
		||||
						<div class="dib filter library mr2"><input data-type="library" class="v-mid mr1" type="checkbox" id="timeline-checkbox-library" checked /><label class="v-mid" for="timeline-checkbox-library">Library (<span class="count">{{ .NumLibrary }}</span>)</label></div>
 | 
			
		||||
					{{ end }}
 | 
			
		||||
					{{ if gt .NumSnippets 0 }}
 | 
			
		||||
						<div class="dib filter snippets mr2"><input data-type="snippets" class="v-mid mr1" type="checkbox" id="timeline-checkbox-snippets" checked /><label class="v-mid" for="timeline-checkbox-snippets">Snippets (<span class="count">{{ .NumSnippets }}</span>)</label></div>
 | 
			
		||||
					{{ end }}
 | 
			
		||||
| 
						 | 
				
			
			@ -61,7 +55,7 @@
 | 
			
		|||
 | 
			
		||||
			<div class="pair flex flex-wrap">
 | 
			
		||||
				<div class="key flex-auto mr1">Posts</div>
 | 
			
		||||
				<div class="value">{{ add .NumForums .NumBlogs .NumWiki .NumLibrary }}</div>
 | 
			
		||||
				<div class="value">{{ add .NumForums .NumBlogs }}</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{{ if .ProfileUser.Email }}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -218,11 +218,7 @@ var HMNTemplateFuncs = template.FuncMap{
 | 
			
		|||
		if item.Type == TimelineTypeForumThread ||
 | 
			
		||||
			item.Type == TimelineTypeForumReply ||
 | 
			
		||||
			item.Type == TimelineTypeBlogPost ||
 | 
			
		||||
			item.Type == TimelineTypeBlogComment ||
 | 
			
		||||
			item.Type == TimelineTypeWikiCreate ||
 | 
			
		||||
			item.Type == TimelineTypeWikiEdit ||
 | 
			
		||||
			item.Type == TimelineTypeWikiTalk ||
 | 
			
		||||
			item.Type == TimelineTypeLibraryComment {
 | 
			
		||||
			item.Type == TimelineTypeBlogComment {
 | 
			
		||||
 | 
			
		||||
			return true
 | 
			
		||||
		}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -39,7 +39,6 @@ type Header struct {
 | 
			
		|||
	ProjectIndexUrl    string
 | 
			
		||||
	BlogUrl            string
 | 
			
		||||
	ForumsUrl          string
 | 
			
		||||
	WikiUrl            string
 | 
			
		||||
	LibraryUrl         string
 | 
			
		||||
	ManifestoUrl       string
 | 
			
		||||
	EpisodeGuideUrl    string
 | 
			
		||||
| 
						 | 
				
			
			@ -109,7 +108,6 @@ type Project struct {
 | 
			
		|||
 | 
			
		||||
	HasBlog    bool
 | 
			
		||||
	HasForum   bool
 | 
			
		||||
	HasWiki    bool
 | 
			
		||||
	HasLibrary bool
 | 
			
		||||
 | 
			
		||||
	UUID         string
 | 
			
		||||
| 
						 | 
				
			
			@ -202,10 +200,6 @@ const (
 | 
			
		|||
	PostTypeBlogComment
 | 
			
		||||
	PostTypeForumThread
 | 
			
		||||
	PostTypeForumReply
 | 
			
		||||
	PostTypeWikiCreate
 | 
			
		||||
	PostTypeWikiTalk
 | 
			
		||||
	PostTypeWikiEdit
 | 
			
		||||
	PostTypeLibraryComment
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Data from post_list_item.html
 | 
			
		||||
| 
						 | 
				
			
			@ -253,12 +247,6 @@ const (
 | 
			
		|||
	TimelineTypeBlogPost
 | 
			
		||||
	TimelineTypeBlogComment
 | 
			
		||||
 | 
			
		||||
	TimelineTypeWikiCreate
 | 
			
		||||
	TimelineTypeWikiEdit
 | 
			
		||||
	TimelineTypeWikiTalk
 | 
			
		||||
 | 
			
		||||
	TimelineTypeLibraryComment
 | 
			
		||||
 | 
			
		||||
	TimelineTypeSnippetImage
 | 
			
		||||
	TimelineTypeSnippetVideo
 | 
			
		||||
	TimelineTypeSnippetAudio
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -37,11 +37,11 @@ func Feed(c *RequestContext) ResponseData {
 | 
			
		|||
		FROM
 | 
			
		||||
			handmade_post AS post
 | 
			
		||||
		WHERE
 | 
			
		||||
			post.category_kind = ANY ($1)
 | 
			
		||||
			post.thread_type = ANY ($1)
 | 
			
		||||
			AND deleted = FALSE
 | 
			
		||||
			AND post.thread_id IS NOT NULL
 | 
			
		||||
		`,
 | 
			
		||||
		[]models.CategoryKind{models.CatKindForum, models.CatKindBlog, models.CatKindWiki, models.CatKindLibraryResource},
 | 
			
		||||
		[]models.ThreadType{models.ThreadTypeForumPost, models.ThreadTypeProjectArticle},
 | 
			
		||||
	)
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
| 
						 | 
				
			
			@ -80,9 +80,9 @@ func Feed(c *RequestContext) ResponseData {
 | 
			
		|||
		currentUserId = &c.CurrentUser.ID
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch category tree")
 | 
			
		||||
	categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
 | 
			
		||||
	lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch subforum tree")
 | 
			
		||||
	subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
 | 
			
		||||
	lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	posts, err := fetchAllPosts(c, lineageBuilder, currentUserId, howManyPostsToSkip, postsPerPage)
 | 
			
		||||
| 
						 | 
				
			
			@ -98,7 +98,7 @@ func Feed(c *RequestContext) ResponseData {
 | 
			
		|||
		BaseData: baseData,
 | 
			
		||||
 | 
			
		||||
		AtomFeedUrl:    hmnurl.BuildAtomFeed(),
 | 
			
		||||
		MarkAllReadUrl: hmnurl.BuildForumCategoryMarkRead(0),
 | 
			
		||||
		MarkAllReadUrl: hmnurl.BuildForumMarkRead(0),
 | 
			
		||||
		Posts:          posts,
 | 
			
		||||
		Pagination:     pagination,
 | 
			
		||||
	}, c.Perf)
 | 
			
		||||
| 
						 | 
				
			
			@ -158,9 +158,9 @@ func AtomFeed(c *RequestContext) ResponseData {
 | 
			
		|||
		feedData.AtomFeedUrl = hmnurl.BuildAtomFeed()
 | 
			
		||||
		feedData.FeedUrl = hmnurl.BuildFeed()
 | 
			
		||||
 | 
			
		||||
		c.Perf.StartBlock("SQL", "Fetch category tree")
 | 
			
		||||
		categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
 | 
			
		||||
		lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
 | 
			
		||||
		c.Perf.StartBlock("SQL", "Fetch subforum tree")
 | 
			
		||||
		subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
 | 
			
		||||
		lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
 | 
			
		||||
		c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
		posts, err := fetchAllPosts(c, lineageBuilder, nil, 0, itemsPerFeed)
 | 
			
		||||
| 
						 | 
				
			
			@ -303,18 +303,16 @@ func AtomFeed(c *RequestContext) ResponseData {
 | 
			
		|||
	return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func fetchAllPosts(c *RequestContext, lineageBuilder *models.CategoryLineageBuilder, currentUserID *int, offset int, limit int) ([]templates.PostListItem, error) {
 | 
			
		||||
func fetchAllPosts(c *RequestContext, lineageBuilder *models.SubforumLineageBuilder, currentUserID *int, offset int, limit int) ([]templates.PostListItem, error) {
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch posts")
 | 
			
		||||
	type feedPostQuery struct {
 | 
			
		||||
		Post                 models.Post        `db:"post"`
 | 
			
		||||
		PostVersion          models.PostVersion `db:"version"`
 | 
			
		||||
		Thread               models.Thread      `db:"thread"`
 | 
			
		||||
		Cat                models.Category         `db:"cat"`
 | 
			
		||||
		Proj                 models.Project     `db:"proj"`
 | 
			
		||||
		LibraryResource    *models.LibraryResource `db:"lib_resource"`
 | 
			
		||||
		User                 models.User        `db:"auth_user"`
 | 
			
		||||
		ThreadLastReadTime   *time.Time         `db:"tlri.lastread"`
 | 
			
		||||
		CatLastReadTime    *time.Time              `db:"clri.lastread"`
 | 
			
		||||
		SubforumLastReadTime *time.Time         `db:"slri.lastread"`
 | 
			
		||||
	}
 | 
			
		||||
	posts, err := db.Query(c.Context(), c.Conn, feedPostQuery{},
 | 
			
		||||
		`
 | 
			
		||||
| 
						 | 
				
			
			@ -323,27 +321,25 @@ func fetchAllPosts(c *RequestContext, lineageBuilder *models.CategoryLineageBuil
 | 
			
		|||
			handmade_post AS post
 | 
			
		||||
			JOIN handmade_postversion AS version ON version.id = post.current_id
 | 
			
		||||
			JOIN handmade_thread AS thread ON thread.id = post.thread_id
 | 
			
		||||
			JOIN handmade_category AS cat ON cat.id = post.category_id
 | 
			
		||||
			JOIN handmade_project AS proj ON proj.id = post.project_id
 | 
			
		||||
			LEFT JOIN handmade_threadlastreadinfo AS tlri ON (
 | 
			
		||||
				tlri.thread_id = post.thread_id
 | 
			
		||||
				AND tlri.user_id = $1
 | 
			
		||||
			)
 | 
			
		||||
			LEFT JOIN handmade_categorylastreadinfo AS clri ON (
 | 
			
		||||
				clri.category_id = post.category_id
 | 
			
		||||
				AND clri.user_id = $1
 | 
			
		||||
			LEFT JOIN handmade_subforumlastreadinfo AS slri ON (
 | 
			
		||||
				slri.subforum_id = thread.subforum_id
 | 
			
		||||
				AND slri.user_id = $1
 | 
			
		||||
			)
 | 
			
		||||
			LEFT JOIN auth_user ON post.author_id = auth_user.id
 | 
			
		||||
			LEFT JOIN handmade_libraryresource as lib_resource ON lib_resource.category_id = post.category_id
 | 
			
		||||
		WHERE
 | 
			
		||||
			post.category_kind = ANY ($2)
 | 
			
		||||
			thread.type = ANY ($2)
 | 
			
		||||
			AND post.deleted = FALSE
 | 
			
		||||
			AND post.thread_id IS NOT NULL
 | 
			
		||||
		ORDER BY postdate DESC
 | 
			
		||||
		LIMIT $3 OFFSET $4
 | 
			
		||||
		`,
 | 
			
		||||
		currentUserID,
 | 
			
		||||
		[]models.CategoryKind{models.CatKindForum, models.CatKindBlog, models.CatKindWiki, models.CatKindLibraryResource},
 | 
			
		||||
		[]models.ThreadType{models.ThreadTypeForumPost, models.ThreadTypeProjectArticle},
 | 
			
		||||
		limit,
 | 
			
		||||
		offset,
 | 
			
		||||
	)
 | 
			
		||||
| 
						 | 
				
			
			@ -360,7 +356,7 @@ func fetchAllPosts(c *RequestContext, lineageBuilder *models.CategoryLineageBuil
 | 
			
		|||
		hasRead := false
 | 
			
		||||
		if postResult.ThreadLastReadTime != nil && postResult.ThreadLastReadTime.After(postResult.Post.PostDate) {
 | 
			
		||||
			hasRead = true
 | 
			
		||||
		} else if postResult.CatLastReadTime != nil && postResult.CatLastReadTime.After(postResult.Post.PostDate) {
 | 
			
		||||
		} else if postResult.SubforumLastReadTime != nil && postResult.SubforumLastReadTime.After(postResult.Post.PostDate) {
 | 
			
		||||
			hasRead = true
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -370,7 +366,6 @@ func fetchAllPosts(c *RequestContext, lineageBuilder *models.CategoryLineageBuil
 | 
			
		|||
			&postResult.Thread,
 | 
			
		||||
			&postResult.Post,
 | 
			
		||||
			&postResult.User,
 | 
			
		||||
			postResult.LibraryResource,
 | 
			
		||||
			!hasRead,
 | 
			
		||||
			true,
 | 
			
		||||
			c.Theme,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,17 +21,17 @@ import (
 | 
			
		|||
	"github.com/jackc/pgx/v4"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type forumCategoryData struct {
 | 
			
		||||
type forumData struct {
 | 
			
		||||
	templates.BaseData
 | 
			
		||||
 | 
			
		||||
	NewThreadUrl string
 | 
			
		||||
	MarkReadUrl  string
 | 
			
		||||
	Threads      []templates.ThreadListItem
 | 
			
		||||
	Pagination   templates.Pagination
 | 
			
		||||
	Subcategories []forumSubcategoryData
 | 
			
		||||
	Subforums    []forumSubforumData
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type forumSubcategoryData struct {
 | 
			
		||||
type forumSubforumData struct {
 | 
			
		||||
	Name         string
 | 
			
		||||
	Url          string
 | 
			
		||||
	Threads      []templates.ThreadListItem
 | 
			
		||||
| 
						 | 
				
			
			@ -50,7 +50,7 @@ type editorData struct {
 | 
			
		|||
	PostReplyingTo *templates.Post
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func ForumCategory(c *RequestContext) ResponseData {
 | 
			
		||||
func Forum(c *RequestContext) ResponseData {
 | 
			
		||||
	const threadsPerPage = 25
 | 
			
		||||
 | 
			
		||||
	cd, ok := getCommonForumData(c)
 | 
			
		||||
| 
						 | 
				
			
			@ -58,7 +58,7 @@ func ForumCategory(c *RequestContext) ResponseData {
 | 
			
		|||
		return FourOhFour(c)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID)
 | 
			
		||||
	currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID)
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch count of page threads")
 | 
			
		||||
	numThreads, err := db.QueryInt(c.Context(), c.Conn,
 | 
			
		||||
| 
						 | 
				
			
			@ -66,10 +66,10 @@ func ForumCategory(c *RequestContext) ResponseData {
 | 
			
		|||
		SELECT COUNT(*)
 | 
			
		||||
		FROM handmade_thread AS thread
 | 
			
		||||
		WHERE
 | 
			
		||||
			thread.category_id = $1
 | 
			
		||||
			thread.subforum_id = $1
 | 
			
		||||
			AND NOT thread.deleted
 | 
			
		||||
		`,
 | 
			
		||||
		cd.CatID,
 | 
			
		||||
		cd.SubforumID,
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		panic(oops.New(err, "failed to get count of threads"))
 | 
			
		||||
| 
						 | 
				
			
			@ -84,11 +84,11 @@ func ForumCategory(c *RequestContext) ResponseData {
 | 
			
		|||
		if pageParsed, err := strconv.Atoi(pageString); err == nil {
 | 
			
		||||
			page = pageParsed
 | 
			
		||||
		} else {
 | 
			
		||||
			return c.Redirect(hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, 1), http.StatusSeeOther)
 | 
			
		||||
			return c.Redirect(hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1), http.StatusSeeOther)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	if page < 1 || numPages < page {
 | 
			
		||||
		return c.Redirect(hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page, numPages)), http.StatusSeeOther)
 | 
			
		||||
		return c.Redirect(hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page, numPages)), http.StatusSeeOther)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	howManyThreadsToSkip := (page - 1) * threadsPerPage
 | 
			
		||||
| 
						 | 
				
			
			@ -106,7 +106,7 @@ func ForumCategory(c *RequestContext) ResponseData {
 | 
			
		|||
		FirstUser          *models.User  `db:"firstuser"`
 | 
			
		||||
		LastUser           *models.User  `db:"lastuser"`
 | 
			
		||||
		ThreadLastReadTime *time.Time    `db:"tlri.lastread"`
 | 
			
		||||
		CatLastReadTime    *time.Time    `db:"clri.lastread"`
 | 
			
		||||
		ForumLastReadTime  *time.Time    `db:"slri.lastread"`
 | 
			
		||||
	}
 | 
			
		||||
	itMainThreads, err := db.Query(c.Context(), c.Conn, threadQueryResult{},
 | 
			
		||||
		`
 | 
			
		||||
| 
						 | 
				
			
			@ -121,17 +121,17 @@ func ForumCategory(c *RequestContext) ResponseData {
 | 
			
		|||
				tlri.thread_id = thread.id
 | 
			
		||||
				AND tlri.user_id = $2
 | 
			
		||||
			)
 | 
			
		||||
			LEFT JOIN handmade_categorylastreadinfo AS clri ON (
 | 
			
		||||
				clri.category_id = $1
 | 
			
		||||
				AND clri.user_id = $2
 | 
			
		||||
			LEFT JOIN handmade_subforumlastreadinfo AS slri ON (
 | 
			
		||||
				slri.subforum_id = $1
 | 
			
		||||
				AND slri.user_id = $2
 | 
			
		||||
			)
 | 
			
		||||
		WHERE
 | 
			
		||||
			thread.category_id = $1
 | 
			
		||||
			thread.subforum_id = $1
 | 
			
		||||
			AND NOT thread.deleted
 | 
			
		||||
		ORDER BY lastpost.postdate DESC
 | 
			
		||||
		LIMIT $3 OFFSET $4
 | 
			
		||||
		`,
 | 
			
		||||
		cd.CatID,
 | 
			
		||||
		cd.SubforumID,
 | 
			
		||||
		currentUserId,
 | 
			
		||||
		threadsPerPage,
 | 
			
		||||
		howManyThreadsToSkip,
 | 
			
		||||
| 
						 | 
				
			
			@ -146,13 +146,13 @@ func ForumCategory(c *RequestContext) ResponseData {
 | 
			
		|||
		hasRead := false
 | 
			
		||||
		if row.ThreadLastReadTime != nil && row.ThreadLastReadTime.After(row.LastPost.PostDate) {
 | 
			
		||||
			hasRead = true
 | 
			
		||||
		} else if row.CatLastReadTime != nil && row.CatLastReadTime.After(row.LastPost.PostDate) {
 | 
			
		||||
		} else if row.ForumLastReadTime != nil && row.ForumLastReadTime.After(row.LastPost.PostDate) {
 | 
			
		||||
			hasRead = true
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return templates.ThreadListItem{
 | 
			
		||||
			Title:     row.Thread.Title,
 | 
			
		||||
			Url:       hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(row.Thread.CategoryID), row.Thread.ID, row.Thread.Title, 1),
 | 
			
		||||
			Url:       hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(*row.Thread.SubforumID), row.Thread.ID, row.Thread.Title, 1),
 | 
			
		||||
			FirstUser: templates.UserToTemplate(row.FirstUser, c.Theme),
 | 
			
		||||
			FirstDate: row.FirstPost.PostDate,
 | 
			
		||||
			LastUser:  templates.UserToTemplate(row.LastUser, c.Theme),
 | 
			
		||||
| 
						 | 
				
			
			@ -169,32 +169,32 @@ func ForumCategory(c *RequestContext) ResponseData {
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	// ---------------------
 | 
			
		||||
	// Subcategory things
 | 
			
		||||
	// Subforum things
 | 
			
		||||
	// ---------------------
 | 
			
		||||
 | 
			
		||||
	var subcats []forumSubcategoryData
 | 
			
		||||
	var subforums []forumSubforumData
 | 
			
		||||
	if page == 1 {
 | 
			
		||||
		subcatNodes := cd.CategoryTree[cd.CatID].Children
 | 
			
		||||
		subforumNodes := cd.SubforumTree[cd.SubforumID].Children
 | 
			
		||||
 | 
			
		||||
		for _, catNode := range subcatNodes {
 | 
			
		||||
			c.Perf.StartBlock("SQL", "Fetch count of subcategory threads")
 | 
			
		||||
			// TODO(asaf): [PERF] [MINOR] Consider replacing querying count per subcat with a single query for all cats with GROUP BY.
 | 
			
		||||
		for _, sfNode := range subforumNodes {
 | 
			
		||||
			c.Perf.StartBlock("SQL", "Fetch count of subforum threads")
 | 
			
		||||
			// TODO(asaf): [PERF] [MINOR] Consider replacing querying count per subforum with a single query for all subforums with GROUP BY.
 | 
			
		||||
			numThreads, err := db.QueryInt(c.Context(), c.Conn,
 | 
			
		||||
				`
 | 
			
		||||
				SELECT COUNT(*)
 | 
			
		||||
				FROM handmade_thread AS thread
 | 
			
		||||
				WHERE
 | 
			
		||||
					thread.category_id = $1
 | 
			
		||||
					thread.subforum_id = $1
 | 
			
		||||
					AND NOT thread.deleted
 | 
			
		||||
				`,
 | 
			
		||||
				catNode.ID,
 | 
			
		||||
				sfNode.ID,
 | 
			
		||||
			)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				panic(oops.New(err, "failed to get count of threads"))
 | 
			
		||||
			}
 | 
			
		||||
			c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
			c.Perf.StartBlock("SQL", "Fetch subcategory threads")
 | 
			
		||||
			c.Perf.StartBlock("SQL", "Fetch subforum threads")
 | 
			
		||||
			// TODO(asaf): [PERF] [MINOR] Consider batching these.
 | 
			
		||||
			itThreads, err := db.Query(c.Context(), c.Conn, threadQueryResult{},
 | 
			
		||||
				`
 | 
			
		||||
| 
						 | 
				
			
			@ -209,17 +209,17 @@ func ForumCategory(c *RequestContext) ResponseData {
 | 
			
		|||
						tlri.thread_id = thread.id
 | 
			
		||||
						AND tlri.user_id = $2
 | 
			
		||||
					)
 | 
			
		||||
					LEFT JOIN handmade_categorylastreadinfo AS clri ON (
 | 
			
		||||
						clri.category_id = $1
 | 
			
		||||
						AND clri.user_id = $2
 | 
			
		||||
					LEFT JOIN handmade_subforumlastreadinfo AS slri ON (
 | 
			
		||||
						slri.subforum_id = $1
 | 
			
		||||
						AND slri.user_id = $2
 | 
			
		||||
					)
 | 
			
		||||
				WHERE
 | 
			
		||||
					thread.category_id = $1
 | 
			
		||||
					thread.subforum_id = $1
 | 
			
		||||
					AND NOT thread.deleted
 | 
			
		||||
				ORDER BY lastpost.postdate DESC
 | 
			
		||||
				LIMIT 3
 | 
			
		||||
				`,
 | 
			
		||||
				catNode.ID,
 | 
			
		||||
				sfNode.ID,
 | 
			
		||||
				currentUserId,
 | 
			
		||||
			)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
| 
						 | 
				
			
			@ -234,9 +234,9 @@ func ForumCategory(c *RequestContext) ResponseData {
 | 
			
		|||
				threads = append(threads, makeThreadListItem(threadRow))
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			subcats = append(subcats, forumSubcategoryData{
 | 
			
		||||
				Name:         *catNode.Name,
 | 
			
		||||
				Url:          hmnurl.BuildForumCategory(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(catNode.ID), 1),
 | 
			
		||||
			subforums = append(subforums, forumSubforumData{
 | 
			
		||||
				Name:         sfNode.Name,
 | 
			
		||||
				Url:          hmnurl.BuildForum(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(sfNode.ID), 1),
 | 
			
		||||
				Threads:      threads,
 | 
			
		||||
				TotalThreads: numThreads,
 | 
			
		||||
			})
 | 
			
		||||
| 
						 | 
				
			
			@ -249,53 +249,53 @@ func ForumCategory(c *RequestContext) ResponseData {
 | 
			
		|||
 | 
			
		||||
	baseData := getBaseData(c)
 | 
			
		||||
	baseData.Title = c.CurrentProject.Name + " Forums"
 | 
			
		||||
	baseData.Breadcrumbs = []templates.Breadcrumb{ // TODO(ben): This is wrong; it needs to account for subcategories.
 | 
			
		||||
	baseData.Breadcrumbs = []templates.Breadcrumb{ // TODO(ben): This is wrong; it needs to account for subforums.
 | 
			
		||||
		{
 | 
			
		||||
			Name: c.CurrentProject.Name,
 | 
			
		||||
			Url:  hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Name:    "Forums",
 | 
			
		||||
			Url:     hmnurl.BuildForumCategory(c.CurrentProject.Slug, nil, 1),
 | 
			
		||||
			Url:     hmnurl.BuildForum(c.CurrentProject.Slug, nil, 1),
 | 
			
		||||
			Current: true,
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	currentSubforums := cd.LineageBuilder.GetSubforumLineage(cd.CatID)
 | 
			
		||||
	currentSubforums := cd.LineageBuilder.GetSubforumLineage(cd.SubforumID)
 | 
			
		||||
	for i, subforum := range currentSubforums {
 | 
			
		||||
		baseData.Breadcrumbs = append(baseData.Breadcrumbs, templates.Breadcrumb{
 | 
			
		||||
			Name: *subforum.Name, // NOTE(asaf): All subforum categories must have names.
 | 
			
		||||
			Url:  hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs[0:i+1], 1),
 | 
			
		||||
			Name: subforum.Name,
 | 
			
		||||
			Url:  hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs[0:i+1], 1),
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var res ResponseData
 | 
			
		||||
	res.MustWriteTemplate("forum_category.html", forumCategoryData{
 | 
			
		||||
	res.MustWriteTemplate("forum.html", forumData{
 | 
			
		||||
		BaseData:     baseData,
 | 
			
		||||
		NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs, false),
 | 
			
		||||
		MarkReadUrl:  hmnurl.BuildForumCategoryMarkRead(cd.CatID),
 | 
			
		||||
		MarkReadUrl:  hmnurl.BuildForumMarkRead(cd.SubforumID),
 | 
			
		||||
		Threads:      threads,
 | 
			
		||||
		Pagination: templates.Pagination{
 | 
			
		||||
			Current: page,
 | 
			
		||||
			Total:   numPages,
 | 
			
		||||
 | 
			
		||||
			FirstUrl:    hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, 1),
 | 
			
		||||
			LastUrl:     hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, numPages),
 | 
			
		||||
			NextUrl:     hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page+1, numPages)),
 | 
			
		||||
			PreviousUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page-1, numPages)),
 | 
			
		||||
			FirstUrl:    hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1),
 | 
			
		||||
			LastUrl:     hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, numPages),
 | 
			
		||||
			NextUrl:     hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page+1, numPages)),
 | 
			
		||||
			PreviousUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page-1, numPages)),
 | 
			
		||||
		},
 | 
			
		||||
		Subcategories: subcats,
 | 
			
		||||
		Subforums: subforums,
 | 
			
		||||
	}, c.Perf)
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func ForumCategoryMarkRead(c *RequestContext) ResponseData {
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch category tree")
 | 
			
		||||
	categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
 | 
			
		||||
	lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
 | 
			
		||||
func ForumMarkRead(c *RequestContext) ResponseData {
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch subforum tree")
 | 
			
		||||
	subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
 | 
			
		||||
	lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	catId, err := strconv.Atoi(c.PathParams["catid"])
 | 
			
		||||
	sfId, err := strconv.Atoi(c.PathParams["sfid"])
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return FourOhFour(c)
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -307,28 +307,28 @@ func ForumCategoryMarkRead(c *RequestContext) ResponseData {
 | 
			
		|||
	defer tx.Rollback(c.Context())
 | 
			
		||||
 | 
			
		||||
	// TODO(ben): Rework this logic when we rework blogs, threads, etc.
 | 
			
		||||
	catIds := []int{catId}
 | 
			
		||||
	if catId == 0 {
 | 
			
		||||
	sfIds := []int{sfId}
 | 
			
		||||
	if sfId == 0 {
 | 
			
		||||
		// Select all categories
 | 
			
		||||
		type catIdResult struct {
 | 
			
		||||
			CatID int `db:"id"`
 | 
			
		||||
		type sfIdResult struct {
 | 
			
		||||
			SubforumID int `db:"id"`
 | 
			
		||||
		}
 | 
			
		||||
		cats, err := db.Query(c.Context(), tx, catIdResult{},
 | 
			
		||||
		cats, err := db.Query(c.Context(), tx, sfIdResult{},
 | 
			
		||||
			`
 | 
			
		||||
			SELECT $columns
 | 
			
		||||
			FROM handmade_category
 | 
			
		||||
			FROM handmade_subforum
 | 
			
		||||
			WHERE kind = ANY ($1)
 | 
			
		||||
			`,
 | 
			
		||||
			[]models.CategoryKind{models.CatKindBlog, models.CatKindForum, models.CatKindWiki, models.CatKindLibraryResource},
 | 
			
		||||
			[]models.ThreadType{models.ThreadTypeProjectArticle, models.ThreadTypeForumPost},
 | 
			
		||||
		)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch category IDs for CLRI"))
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		catIdResults := cats.ToSlice()
 | 
			
		||||
		catIds = make([]int, len(catIdResults))
 | 
			
		||||
		sfIds = make([]int, len(catIdResults))
 | 
			
		||||
		for i, res := range catIdResults {
 | 
			
		||||
			catIds[i] = res.(*catIdResult).CatID
 | 
			
		||||
			sfIds[i] = res.(*sfIdResult).SubforumID
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -342,7 +342,7 @@ func ForumCategoryMarkRead(c *RequestContext) ResponseData {
 | 
			
		|||
		ON CONFLICT (category_id, user_id) DO UPDATE
 | 
			
		||||
			SET lastread = EXCLUDED.lastread
 | 
			
		||||
		`,
 | 
			
		||||
		catIds,
 | 
			
		||||
		sfIds,
 | 
			
		||||
		c.CurrentUser.ID,
 | 
			
		||||
		time.Now(),
 | 
			
		||||
	)
 | 
			
		||||
| 
						 | 
				
			
			@ -364,7 +364,7 @@ func ForumCategoryMarkRead(c *RequestContext) ResponseData {
 | 
			
		|||
					category_id = ANY ($1)
 | 
			
		||||
			)
 | 
			
		||||
		`,
 | 
			
		||||
		catIds,
 | 
			
		||||
		sfIds,
 | 
			
		||||
		c.CurrentUser.ID,
 | 
			
		||||
	)
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
| 
						 | 
				
			
			@ -378,10 +378,10 @@ func ForumCategoryMarkRead(c *RequestContext) ResponseData {
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	var redirUrl string
 | 
			
		||||
	if catId == 0 {
 | 
			
		||||
	if sfId == 0 {
 | 
			
		||||
		redirUrl = hmnurl.BuildFeed()
 | 
			
		||||
	} else {
 | 
			
		||||
		redirUrl = hmnurl.BuildForumCategory(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(catId), 1)
 | 
			
		||||
		redirUrl = hmnurl.BuildForum(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(sfId), 1)
 | 
			
		||||
	}
 | 
			
		||||
	return c.Redirect(redirUrl, http.StatusSeeOther)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -392,7 +392,7 @@ type forumThreadData struct {
 | 
			
		|||
	Thread templates.Thread
 | 
			
		||||
	Posts  []templates.Post
 | 
			
		||||
 | 
			
		||||
	CategoryUrl string
 | 
			
		||||
	SubforumUrl string
 | 
			
		||||
	ReplyUrl    string
 | 
			
		||||
	Pagination  templates.Pagination
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -405,7 +405,7 @@ func ForumThread(c *RequestContext) ResponseData {
 | 
			
		|||
		return FourOhFour(c)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID)
 | 
			
		||||
	currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID)
 | 
			
		||||
 | 
			
		||||
	thread := cd.FetchThread(c.Context(), c.Conn)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -519,7 +519,7 @@ func ForumThread(c *RequestContext) ResponseData {
 | 
			
		|||
		BaseData:    baseData,
 | 
			
		||||
		Thread:      templates.ThreadToTemplate(thread),
 | 
			
		||||
		Posts:       posts,
 | 
			
		||||
		CategoryUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, 1),
 | 
			
		||||
		SubforumUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1),
 | 
			
		||||
		ReplyUrl:    hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, *thread.FirstID),
 | 
			
		||||
		Pagination:  pagination,
 | 
			
		||||
	}, c.Perf)
 | 
			
		||||
| 
						 | 
				
			
			@ -542,12 +542,10 @@ func ForumPostRedirect(c *RequestContext) ResponseData {
 | 
			
		|||
		FROM
 | 
			
		||||
			handmade_post AS post
 | 
			
		||||
		WHERE
 | 
			
		||||
			post.category_id = $1
 | 
			
		||||
			AND post.thread_id = $2
 | 
			
		||||
			post.thread_id = $1
 | 
			
		||||
			AND NOT post.deleted
 | 
			
		||||
		ORDER BY postdate
 | 
			
		||||
		`,
 | 
			
		||||
		cd.CatID,
 | 
			
		||||
		cd.ThreadID,
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
| 
						 | 
				
			
			@ -588,7 +586,7 @@ func ForumPostRedirect(c *RequestContext) ResponseData {
 | 
			
		|||
 | 
			
		||||
	return c.Redirect(hmnurl.BuildForumThreadWithPostHash(
 | 
			
		||||
		c.CurrentProject.Slug,
 | 
			
		||||
		cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID),
 | 
			
		||||
		cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID),
 | 
			
		||||
		cd.ThreadID,
 | 
			
		||||
		threadTitle,
 | 
			
		||||
		page,
 | 
			
		||||
| 
						 | 
				
			
			@ -610,7 +608,7 @@ func ForumNewThread(c *RequestContext) ResponseData {
 | 
			
		|||
	var res ResponseData
 | 
			
		||||
	res.MustWriteTemplate("editor.html", editorData{
 | 
			
		||||
		BaseData:    baseData,
 | 
			
		||||
		SubmitUrl:   hmnurl.BuildForumNewThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), true),
 | 
			
		||||
		SubmitUrl:   hmnurl.BuildForumNewThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), true),
 | 
			
		||||
		SubmitLabel: "Post New Thread",
 | 
			
		||||
	}, c.Perf)
 | 
			
		||||
	return res
 | 
			
		||||
| 
						 | 
				
			
			@ -643,13 +641,15 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
 | 
			
		|||
	var threadId int
 | 
			
		||||
	err = tx.QueryRow(c.Context(),
 | 
			
		||||
		`
 | 
			
		||||
		INSERT INTO handmade_thread (title, sticky, category_id, first_id, last_id)
 | 
			
		||||
		VALUES ($1, $2, $3, $4, $5)
 | 
			
		||||
		INSERT INTO handmade_thread (title, sticky, type, project_id, subforum_id, first_id, last_id)
 | 
			
		||||
		VALUES ($1, $2, $3, $4, $5, $6, $7)
 | 
			
		||||
		RETURNING id
 | 
			
		||||
		`,
 | 
			
		||||
		title,
 | 
			
		||||
		sticky,
 | 
			
		||||
		cd.CatID,
 | 
			
		||||
		models.ThreadTypeForumPost,
 | 
			
		||||
		c.CurrentProject.ID,
 | 
			
		||||
		cd.SubforumID,
 | 
			
		||||
		-1,
 | 
			
		||||
		-1,
 | 
			
		||||
	).Scan(&threadId)
 | 
			
		||||
| 
						 | 
				
			
			@ -657,7 +657,7 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
 | 
			
		|||
		panic(oops.New(err, "failed to create thread"))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	postId, _ := createNewForumPostAndVersion(c.Context(), tx, cd.CatID, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, nil)
 | 
			
		||||
	postId, _ := createNewForumPostAndVersion(c.Context(), tx, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, nil)
 | 
			
		||||
 | 
			
		||||
	// Update thread with post id
 | 
			
		||||
	_, err = tx.Exec(c.Context(),
 | 
			
		||||
| 
						 | 
				
			
			@ -680,7 +680,7 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
 | 
			
		|||
		return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new forum thread"))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	newThreadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), threadId, title, 1)
 | 
			
		||||
	newThreadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), threadId, title, 1)
 | 
			
		||||
	return c.Redirect(newThreadUrl, http.StatusSeeOther)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -693,7 +693,7 @@ func ForumPostReply(c *RequestContext) ResponseData {
 | 
			
		|||
	postData := cd.FetchPostAndStuff(c.Context(), c.Conn)
 | 
			
		||||
 | 
			
		||||
	baseData := getBaseData(c)
 | 
			
		||||
	baseData.Title = fmt.Sprintf("Replying to \"%s\" | %s", postData.Thread.Title, *cd.CategoryTree[cd.CatID].Name)
 | 
			
		||||
	baseData.Title = fmt.Sprintf("Replying to \"%s\" | %s", postData.Thread.Title, cd.SubforumTree[cd.SubforumID].Name)
 | 
			
		||||
	baseData.MathjaxEnabled = true
 | 
			
		||||
	// TODO(ben): Set breadcrumbs
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -703,7 +703,7 @@ func ForumPostReply(c *RequestContext) ResponseData {
 | 
			
		|||
	var res ResponseData
 | 
			
		||||
	res.MustWriteTemplate("editor.html", editorData{
 | 
			
		||||
		BaseData:    baseData,
 | 
			
		||||
		SubmitUrl:   hmnurl.BuildForumPostReply(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.ThreadID, cd.PostID),
 | 
			
		||||
		SubmitUrl:   hmnurl.BuildForumPostReply(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID),
 | 
			
		||||
		SubmitLabel: "Submit Reply",
 | 
			
		||||
 | 
			
		||||
		Title:          "Replying to post",
 | 
			
		||||
| 
						 | 
				
			
			@ -729,14 +729,14 @@ func ForumPostReplySubmit(c *RequestContext) ResponseData {
 | 
			
		|||
 | 
			
		||||
	unparsed := c.Req.Form.Get("body")
 | 
			
		||||
 | 
			
		||||
	newPostId, _ := createNewForumPostAndVersion(c.Context(), tx, cd.CatID, cd.ThreadID, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, &cd.PostID)
 | 
			
		||||
	newPostId, _ := createNewForumPostAndVersion(c.Context(), tx, cd.ThreadID, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, &cd.PostID)
 | 
			
		||||
 | 
			
		||||
	err = tx.Commit(c.Context())
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to forum post"))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	newPostUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.ThreadID, newPostId)
 | 
			
		||||
	newPostUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, newPostId)
 | 
			
		||||
	return c.Redirect(newPostUrl, http.StatusSeeOther)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -753,7 +753,7 @@ func ForumPostEdit(c *RequestContext) ResponseData {
 | 
			
		|||
	postData := cd.FetchPostAndStuff(c.Context(), c.Conn)
 | 
			
		||||
 | 
			
		||||
	baseData := getBaseData(c)
 | 
			
		||||
	baseData.Title = fmt.Sprintf("Editing \"%s\" | %s", postData.Thread.Title, *cd.CategoryTree[cd.CatID].Name)
 | 
			
		||||
	baseData.Title = fmt.Sprintf("Editing \"%s\" | %s", postData.Thread.Title, cd.SubforumTree[cd.SubforumID].Name)
 | 
			
		||||
	baseData.MathjaxEnabled = true
 | 
			
		||||
	// TODO(ben): Set breadcrumbs
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -763,7 +763,7 @@ func ForumPostEdit(c *RequestContext) ResponseData {
 | 
			
		|||
	var res ResponseData
 | 
			
		||||
	res.MustWriteTemplate("editor.html", editorData{
 | 
			
		||||
		BaseData:    baseData,
 | 
			
		||||
		SubmitUrl:   hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.ThreadID, cd.PostID),
 | 
			
		||||
		SubmitUrl:   hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID),
 | 
			
		||||
		Title:       postData.Thread.Title,
 | 
			
		||||
		SubmitLabel: "Submit Edited Post",
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -801,7 +801,7 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData {
 | 
			
		|||
		return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit forum post"))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	postUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.ThreadID, cd.PostID)
 | 
			
		||||
	postUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
 | 
			
		||||
	return c.Redirect(postUrl, http.StatusSeeOther)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -818,7 +818,7 @@ func ForumPostDelete(c *RequestContext) ResponseData {
 | 
			
		|||
	postData := cd.FetchPostAndStuff(c.Context(), c.Conn)
 | 
			
		||||
 | 
			
		||||
	baseData := getBaseData(c)
 | 
			
		||||
	baseData.Title = fmt.Sprintf("Deleting post in \"%s\" | %s", postData.Thread.Title, *cd.CategoryTree[cd.CatID].Name)
 | 
			
		||||
	baseData.Title = fmt.Sprintf("Deleting post in \"%s\" | %s", postData.Thread.Title, cd.SubforumTree[cd.SubforumID].Name)
 | 
			
		||||
	baseData.MathjaxEnabled = true
 | 
			
		||||
	// TODO(ben): Set breadcrumbs
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -834,7 +834,7 @@ func ForumPostDelete(c *RequestContext) ResponseData {
 | 
			
		|||
	var res ResponseData
 | 
			
		||||
	res.MustWriteTemplate("forum_post_delete.html", forumPostDeleteData{
 | 
			
		||||
		BaseData:  baseData,
 | 
			
		||||
		SubmitUrl: hmnurl.BuildForumPostDelete(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.ThreadID, cd.PostID),
 | 
			
		||||
		SubmitUrl: hmnurl.BuildForumPostDelete(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID),
 | 
			
		||||
		Post:      templatePost,
 | 
			
		||||
	}, c.Perf)
 | 
			
		||||
	return res
 | 
			
		||||
| 
						 | 
				
			
			@ -895,7 +895,7 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData {
 | 
			
		|||
			return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete thread and posts when deleting the first post"))
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		forumUrl := hmnurl.BuildForumCategory(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), 1)
 | 
			
		||||
		forumUrl := hmnurl.BuildForum(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), 1)
 | 
			
		||||
		return c.Redirect(forumUrl, http.StatusSeeOther)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -926,24 +926,23 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData {
 | 
			
		|||
		return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete post"))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	threadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.CatID), cd.ThreadID, "", 1) // TODO: Go to the last page of the thread? Or the post before the post we just deleted?
 | 
			
		||||
	threadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, "", 1) // TODO: Go to the last page of the thread? Or the post before the post we just deleted?
 | 
			
		||||
	return c.Redirect(threadUrl, http.StatusSeeOther)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func createNewForumPostAndVersion(ctx context.Context, tx pgx.Tx, catId, threadId, userId, projectId int, unparsedContent string, ipString string, replyId *int) (postId, versionId int) {
 | 
			
		||||
func createNewForumPostAndVersion(ctx context.Context, tx pgx.Tx, threadId, userId, projectId int, unparsedContent string, ipString string, replyId *int) (postId, versionId int) {
 | 
			
		||||
	// Create post
 | 
			
		||||
	err := tx.QueryRow(ctx,
 | 
			
		||||
		`
 | 
			
		||||
		INSERT INTO handmade_post (postdate, category_id, thread_id, current_id, author_id, category_kind, project_id, reply_id, preview)
 | 
			
		||||
		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
 | 
			
		||||
		INSERT INTO handmade_post (postdate, thread_id, thread_type, current_id, author_id, project_id, reply_id, preview)
 | 
			
		||||
		VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
 | 
			
		||||
		RETURNING id
 | 
			
		||||
		`,
 | 
			
		||||
		time.Now(),
 | 
			
		||||
		catId,
 | 
			
		||||
		threadId,
 | 
			
		||||
		models.ThreadTypeForumPost,
 | 
			
		||||
		-1,
 | 
			
		||||
		userId,
 | 
			
		||||
		models.CatKindForum,
 | 
			
		||||
		projectId,
 | 
			
		||||
		replyId,
 | 
			
		||||
		"", // empty preview, will be updated later
 | 
			
		||||
| 
						 | 
				
			
			@ -1064,16 +1063,16 @@ func fixThreadPostIds(ctx context.Context, tx pgx.Tx, threadId int) error {
 | 
			
		|||
type commonForumData struct {
 | 
			
		||||
	c *RequestContext
 | 
			
		||||
 | 
			
		||||
	CatID    int
 | 
			
		||||
	SubforumID int
 | 
			
		||||
	ThreadID   int
 | 
			
		||||
	PostID     int
 | 
			
		||||
 | 
			
		||||
	CategoryTree   models.CategoryTree
 | 
			
		||||
	LineageBuilder *models.CategoryLineageBuilder
 | 
			
		||||
	SubforumTree   models.SubforumTree
 | 
			
		||||
	LineageBuilder *models.SubforumLineageBuilder
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Gets data that is used on basically every forums-related route. Parses path params for category,
 | 
			
		||||
Gets data that is used on basically every forums-related route. Parses path params for subforum,
 | 
			
		||||
thread, and post ids and validates that all those resources do in fact exist.
 | 
			
		||||
 | 
			
		||||
Returns false if any data is invalid and you should return a 404.
 | 
			
		||||
| 
						 | 
				
			
			@ -1082,25 +1081,25 @@ func getCommonForumData(c *RequestContext) (commonForumData, bool) {
 | 
			
		|||
	c.Perf.StartBlock("FORUMS", "Fetch common forum data")
 | 
			
		||||
	defer c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch category tree")
 | 
			
		||||
	categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
 | 
			
		||||
	lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch subforum tree")
 | 
			
		||||
	subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
 | 
			
		||||
	lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	res := commonForumData{
 | 
			
		||||
		c:              c,
 | 
			
		||||
		CategoryTree:   categoryTree,
 | 
			
		||||
		SubforumTree:   subforumTree,
 | 
			
		||||
		LineageBuilder: lineageBuilder,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if cats, hasCats := c.PathParams["cats"]; hasCats {
 | 
			
		||||
		catId, valid := validateSubforums(lineageBuilder, c.CurrentProject, cats)
 | 
			
		||||
	if subforums, hasSubforums := c.PathParams["subforums"]; hasSubforums {
 | 
			
		||||
		sfId, valid := validateSubforums(lineageBuilder, c.CurrentProject, subforums)
 | 
			
		||||
		if !valid {
 | 
			
		||||
			return commonForumData{}, false
 | 
			
		||||
		}
 | 
			
		||||
		res.CatID = catId
 | 
			
		||||
		res.SubforumID = sfId
 | 
			
		||||
 | 
			
		||||
		// No need to validate cat here; it's handled by validateSubforums.
 | 
			
		||||
		// No need to validate that subforum exists here; it's handled by validateSubforums.
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if threadIdStr, hasThreadId := c.PathParams["threadid"]; hasThreadId {
 | 
			
		||||
| 
						 | 
				
			
			@ -1117,10 +1116,10 @@ func getCommonForumData(c *RequestContext) (commonForumData, bool) {
 | 
			
		|||
			FROM handmade_thread
 | 
			
		||||
			WHERE
 | 
			
		||||
				id = $1
 | 
			
		||||
				AND category_id = $2
 | 
			
		||||
				AND subforum_id = $2
 | 
			
		||||
			`,
 | 
			
		||||
			res.ThreadID,
 | 
			
		||||
			res.CatID,
 | 
			
		||||
			res.SubforumID,
 | 
			
		||||
		)
 | 
			
		||||
		c.Perf.EndBlock()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
| 
						 | 
				
			
			@ -1146,11 +1145,9 @@ func getCommonForumData(c *RequestContext) (commonForumData, bool) {
 | 
			
		|||
			WHERE
 | 
			
		||||
				id = $1
 | 
			
		||||
				AND thread_id = $2
 | 
			
		||||
				AND category_id = $3
 | 
			
		||||
			`,
 | 
			
		||||
			res.PostID,
 | 
			
		||||
			res.ThreadID,
 | 
			
		||||
			res.CatID,
 | 
			
		||||
		)
 | 
			
		||||
		c.Perf.EndBlock()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
| 
						 | 
				
			
			@ -1180,14 +1177,14 @@ func (cd *commonForumData) FetchThread(ctx context.Context, connOrTx db.ConnOrTx
 | 
			
		|||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
			handmade_thread AS thread
 | 
			
		||||
			JOIN handmade_category AS cat ON cat.id = thread.category_id
 | 
			
		||||
			JOIN handmade_subforum AS sf ON sf.id = thread.subforum_id
 | 
			
		||||
		WHERE
 | 
			
		||||
			thread.id = $1
 | 
			
		||||
			AND NOT thread.deleted
 | 
			
		||||
			AND cat.id = $2
 | 
			
		||||
			AND sf.id = $2
 | 
			
		||||
		`,
 | 
			
		||||
		cd.ThreadID,
 | 
			
		||||
		cd.CatID, // NOTE(asaf): This verifies that the requested thread is under the requested subforum.
 | 
			
		||||
		cd.SubforumID, // NOTE(asaf): This verifies that the requested thread is under the requested subforum.
 | 
			
		||||
	)
 | 
			
		||||
	cd.c.Perf.EndBlock()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
| 
						 | 
				
			
			@ -1225,12 +1222,10 @@ func (cd *commonForumData) FetchPostAndStuff(ctx context.Context, connOrTx db.Co
 | 
			
		|||
			LEFT JOIN auth_user AS author ON post.author_id = author.id
 | 
			
		||||
			LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id
 | 
			
		||||
		WHERE
 | 
			
		||||
			post.category_id = $1
 | 
			
		||||
			AND post.thread_id = $2
 | 
			
		||||
			AND post.id = $3
 | 
			
		||||
			post.thread_id = $1
 | 
			
		||||
			AND post.id = $2
 | 
			
		||||
			AND NOT post.deleted
 | 
			
		||||
		`,
 | 
			
		||||
		cd.CatID,
 | 
			
		||||
		cd.ThreadID,
 | 
			
		||||
		cd.PostID,
 | 
			
		||||
	)
 | 
			
		||||
| 
						 | 
				
			
			@ -1274,27 +1269,27 @@ func (cd *commonForumData) UserCanEditPost(ctx context.Context, connOrTx db.Conn
 | 
			
		|||
	return result.AuthorID != nil && *result.AuthorID == user.ID
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, catPath string) (int, bool) {
 | 
			
		||||
func validateSubforums(lineageBuilder *models.SubforumLineageBuilder, project *models.Project, sfPath string) (int, bool) {
 | 
			
		||||
	if project.ForumID == nil {
 | 
			
		||||
		return -1, false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	subforumCatId := *project.ForumID
 | 
			
		||||
	if len(catPath) == 0 {
 | 
			
		||||
		return subforumCatId, true
 | 
			
		||||
	subforumId := *project.ForumID
 | 
			
		||||
	if len(sfPath) == 0 {
 | 
			
		||||
		return subforumId, true
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	catPath = strings.ToLower(catPath)
 | 
			
		||||
	sfPath = strings.ToLower(sfPath)
 | 
			
		||||
	valid := false
 | 
			
		||||
	catSlugs := strings.Split(catPath, "/")
 | 
			
		||||
	lastSlug := catSlugs[len(catSlugs)-1]
 | 
			
		||||
	sfSlugs := strings.Split(sfPath, "/")
 | 
			
		||||
	lastSlug := sfSlugs[len(sfSlugs)-1]
 | 
			
		||||
	if len(lastSlug) > 0 {
 | 
			
		||||
		lastSlugCatId := lineageBuilder.FindIdBySlug(project.ID, lastSlug)
 | 
			
		||||
		if lastSlugCatId != -1 {
 | 
			
		||||
			subforumSlugs := lineageBuilder.GetSubforumLineageSlugs(lastSlugCatId)
 | 
			
		||||
		lastSlugSfId := lineageBuilder.FindIdBySlug(project.ID, lastSlug)
 | 
			
		||||
		if lastSlugSfId != -1 {
 | 
			
		||||
			subforumSlugs := lineageBuilder.GetSubforumLineageSlugs(lastSlugSfId)
 | 
			
		||||
			allMatch := true
 | 
			
		||||
			for i, subforum := range subforumSlugs {
 | 
			
		||||
				if subforum != catSlugs[i] {
 | 
			
		||||
				if subforum != sfSlugs[i] {
 | 
			
		||||
					allMatch = false
 | 
			
		||||
					break
 | 
			
		||||
				}
 | 
			
		||||
| 
						 | 
				
			
			@ -1302,8 +1297,8 @@ func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *m
 | 
			
		|||
			valid = allMatch
 | 
			
		||||
		}
 | 
			
		||||
		if valid {
 | 
			
		||||
			subforumCatId = lastSlugCatId
 | 
			
		||||
			subforumId = lastSlugSfId
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return subforumCatId, valid
 | 
			
		||||
	return subforumId, valid
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -74,9 +74,9 @@ func Index(c *RequestContext) ResponseData {
 | 
			
		|||
	allProjects := iterProjects.ToSlice()
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch category tree")
 | 
			
		||||
	categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
 | 
			
		||||
	lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch subforum tree")
 | 
			
		||||
	subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
 | 
			
		||||
	lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	var currentUserId *int
 | 
			
		||||
| 
						 | 
				
			
			@ -93,9 +93,8 @@ func Index(c *RequestContext) ResponseData {
 | 
			
		|||
			Post                 models.Post   `db:"post"`
 | 
			
		||||
			Thread               models.Thread `db:"thread"`
 | 
			
		||||
			User                 models.User   `db:"auth_user"`
 | 
			
		||||
			LibraryResource    *models.LibraryResource `db:"lib_resource"`
 | 
			
		||||
			ThreadLastReadTime   *time.Time    `db:"tlri.lastread"`
 | 
			
		||||
			CatLastReadTime    *time.Time              `db:"clri.lastread"`
 | 
			
		||||
			SubforumLastReadTime *time.Time    `db:"slri.lastread"`
 | 
			
		||||
		}
 | 
			
		||||
		projectPostIter, err := db.Query(c.Context(), c.Conn, projectPostQuery{},
 | 
			
		||||
			`
 | 
			
		||||
| 
						 | 
				
			
			@ -107,22 +106,21 @@ func Index(c *RequestContext) ResponseData {
 | 
			
		|||
					tlri.thread_id = post.thread_id
 | 
			
		||||
					AND tlri.user_id = $1
 | 
			
		||||
				)
 | 
			
		||||
				LEFT JOIN handmade_categorylastreadinfo AS clri ON (
 | 
			
		||||
					clri.category_id = post.category_id
 | 
			
		||||
					AND clri.user_id = $1
 | 
			
		||||
				LEFT JOIN handmade_subforumlastreadinfo AS slri ON (
 | 
			
		||||
					slri.subforum_id = thread.subforum_id
 | 
			
		||||
					AND slri.user_id = $1
 | 
			
		||||
				)
 | 
			
		||||
				LEFT JOIN auth_user ON post.author_id = auth_user.id
 | 
			
		||||
				LEFT JOIN handmade_libraryresource as lib_resource ON lib_resource.category_id = post.category_id
 | 
			
		||||
			WHERE
 | 
			
		||||
				post.project_id = $2
 | 
			
		||||
				AND post.category_kind IN ($3, $4, $5, $6)
 | 
			
		||||
				AND post.thread_type = ANY ($3)
 | 
			
		||||
				AND post.deleted = FALSE
 | 
			
		||||
			ORDER BY postdate DESC
 | 
			
		||||
			LIMIT $7
 | 
			
		||||
			LIMIT $4
 | 
			
		||||
			`,
 | 
			
		||||
			currentUserId,
 | 
			
		||||
			proj.ID,
 | 
			
		||||
			models.CatKindBlog, models.CatKindForum, models.CatKindWiki, models.CatKindLibraryResource,
 | 
			
		||||
			[]models.ThreadType{models.ThreadTypeProjectArticle, models.ThreadTypeForumPost},
 | 
			
		||||
			maxPosts,
 | 
			
		||||
		)
 | 
			
		||||
		c.Perf.EndBlock()
 | 
			
		||||
| 
						 | 
				
			
			@ -134,7 +132,7 @@ func Index(c *RequestContext) ResponseData {
 | 
			
		|||
 | 
			
		||||
		forumsUrl := ""
 | 
			
		||||
		if proj.ForumID != nil {
 | 
			
		||||
			forumsUrl = hmnurl.BuildForumCategory(proj.Slug, lineageBuilder.GetSubforumLineageSlugs(*proj.ForumID), 1)
 | 
			
		||||
			forumsUrl = hmnurl.BuildForum(proj.Slug, lineageBuilder.GetSubforumLineageSlugs(*proj.ForumID), 1)
 | 
			
		||||
		} else {
 | 
			
		||||
			c.Logger.Error().Int("ProjectID", proj.ID).Str("ProjectName", proj.Name).Msg("Project fetched by landing page but it doesn't have forums")
 | 
			
		||||
		}
 | 
			
		||||
| 
						 | 
				
			
			@ -150,12 +148,12 @@ func Index(c *RequestContext) ResponseData {
 | 
			
		|||
			hasRead := false
 | 
			
		||||
			if projectPost.ThreadLastReadTime != nil && projectPost.ThreadLastReadTime.After(projectPost.Post.PostDate) {
 | 
			
		||||
				hasRead = true
 | 
			
		||||
			} else if projectPost.CatLastReadTime != nil && projectPost.CatLastReadTime.After(projectPost.Post.PostDate) {
 | 
			
		||||
			} else if projectPost.SubforumLastReadTime != nil && projectPost.SubforumLastReadTime.After(projectPost.Post.PostDate) {
 | 
			
		||||
				hasRead = true
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			featurable := (!proj.IsHMN() &&
 | 
			
		||||
				projectPost.Post.CategoryKind == models.CatKindBlog &&
 | 
			
		||||
				projectPost.Post.ThreadType == models.ThreadTypeProjectArticle &&
 | 
			
		||||
				projectPost.Post.ParentID == nil &&
 | 
			
		||||
				landingPageProject.FeaturedPost == nil)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -197,7 +195,6 @@ func Index(c *RequestContext) ResponseData {
 | 
			
		|||
						&projectPost.Thread,
 | 
			
		||||
						&projectPost.Post,
 | 
			
		||||
						&projectPost.User,
 | 
			
		||||
						projectPost.LibraryResource,
 | 
			
		||||
						!hasRead,
 | 
			
		||||
						false,
 | 
			
		||||
						c.Theme,
 | 
			
		||||
| 
						 | 
				
			
			@ -263,14 +260,14 @@ func Index(c *RequestContext) ResponseData {
 | 
			
		|||
			JOIN handmade_postversion AS ver ON post.current_id = ver.id
 | 
			
		||||
		WHERE
 | 
			
		||||
			post.project_id = $1
 | 
			
		||||
			AND post.category_kind = $2
 | 
			
		||||
			AND thread.type = $2
 | 
			
		||||
			AND post.id = thread.first_id
 | 
			
		||||
			AND NOT thread.deleted
 | 
			
		||||
		ORDER BY post.postdate DESC
 | 
			
		||||
		LIMIT 1
 | 
			
		||||
		`,
 | 
			
		||||
		models.HMNProjectID,
 | 
			
		||||
		models.CatKindBlog,
 | 
			
		||||
		models.ThreadTypeProjectArticle,
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch news post"))
 | 
			
		||||
| 
						 | 
				
			
			@ -294,6 +291,7 @@ func Index(c *RequestContext) ResponseData {
 | 
			
		|||
			LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
 | 
			
		||||
			LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
 | 
			
		||||
		ORDER BY snippet.when DESC
 | 
			
		||||
		LIMIT 20
 | 
			
		||||
		`,
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,33 +7,20 @@ import (
 | 
			
		|||
)
 | 
			
		||||
 | 
			
		||||
// NOTE(asaf): Please don't use this if you already know the kind of the post beforehand. Just call the appropriate build function.
 | 
			
		||||
// You may pass 0 for `libraryResourceId` if the post is not a library resource post.
 | 
			
		||||
func UrlForGenericPost(post *models.Post, subforums []string, threadTitle string, libraryResourceId int, projectSlug string) string {
 | 
			
		||||
	switch post.CategoryKind {
 | 
			
		||||
	case models.CatKindBlog:
 | 
			
		||||
func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder *models.SubforumLineageBuilder, projectSlug string) string {
 | 
			
		||||
	switch post.ThreadType {
 | 
			
		||||
	case models.ThreadTypeProjectArticle:
 | 
			
		||||
		return hmnurl.BuildBlogPost(projectSlug, post.ThreadID, post.ID)
 | 
			
		||||
	case models.CatKindForum:
 | 
			
		||||
		return hmnurl.BuildForumPost(projectSlug, subforums, post.ThreadID, post.ID)
 | 
			
		||||
	case models.CatKindWiki:
 | 
			
		||||
		if post.ParentID == nil {
 | 
			
		||||
			// NOTE(asaf): First post on a wiki "thread" is the wiki article itself
 | 
			
		||||
			return hmnurl.BuildWikiArticle(projectSlug, post.ThreadID, threadTitle)
 | 
			
		||||
		} else {
 | 
			
		||||
			// NOTE(asaf): Subsequent posts on a wiki "thread" are wiki talk posts
 | 
			
		||||
			return hmnurl.BuildWikiTalkPost(projectSlug, post.ThreadID, post.ID)
 | 
			
		||||
		}
 | 
			
		||||
	case models.CatKindLibraryResource:
 | 
			
		||||
		return hmnurl.BuildLibraryPost(projectSlug, libraryResourceId, post.ThreadID, post.ID)
 | 
			
		||||
	case models.ThreadTypeForumPost:
 | 
			
		||||
		return hmnurl.BuildForumPost(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), post.ThreadID, post.ID)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return hmnurl.BuildProjectHomepage(projectSlug)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var PostTypeMap = map[models.CategoryKind][]templates.PostType{
 | 
			
		||||
	models.CatKindBlog:            []templates.PostType{templates.PostTypeBlogPost, templates.PostTypeBlogComment},
 | 
			
		||||
	models.CatKindForum:           []templates.PostType{templates.PostTypeForumThread, templates.PostTypeForumReply},
 | 
			
		||||
	models.CatKindWiki:            []templates.PostType{templates.PostTypeWikiCreate, templates.PostTypeWikiTalk},
 | 
			
		||||
	models.CatKindLibraryResource: []templates.PostType{templates.PostTypeLibraryComment, templates.PostTypeLibraryComment},
 | 
			
		||||
var PostTypeMap = map[models.ThreadType][]templates.PostType{
 | 
			
		||||
	models.ThreadTypeProjectArticle: {templates.PostTypeBlogPost, templates.PostTypeBlogComment},
 | 
			
		||||
	models.ThreadTypeForumPost:      {templates.PostTypeForumThread, templates.PostTypeForumReply},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var PostTypePrefix = map[templates.PostType]string{
 | 
			
		||||
| 
						 | 
				
			
			@ -41,58 +28,53 @@ var PostTypePrefix = map[templates.PostType]string{
 | 
			
		|||
	templates.PostTypeBlogComment: "Blog comment",
 | 
			
		||||
	templates.PostTypeForumThread: "New forum thread",
 | 
			
		||||
	templates.PostTypeForumReply:  "Forum reply",
 | 
			
		||||
	templates.PostTypeWikiCreate:     "New wiki page",
 | 
			
		||||
	templates.PostTypeWikiTalk:       "Wiki comment",
 | 
			
		||||
	templates.PostTypeWikiEdit:       "Wiki edit",
 | 
			
		||||
	templates.PostTypeLibraryComment: "Library comment",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func PostBreadcrumbs(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, post *models.Post, libraryResource *models.LibraryResource) []templates.Breadcrumb {
 | 
			
		||||
func PostBreadcrumbs(lineageBuilder *models.SubforumLineageBuilder, project *models.Project, thread *models.Thread) []templates.Breadcrumb {
 | 
			
		||||
	var result []templates.Breadcrumb
 | 
			
		||||
	result = append(result, templates.Breadcrumb{
 | 
			
		||||
		Name: project.Name,
 | 
			
		||||
		Url:  hmnurl.BuildProjectHomepage(project.Slug),
 | 
			
		||||
	})
 | 
			
		||||
	result = append(result, templates.Breadcrumb{
 | 
			
		||||
		Name: CategoryKindDisplayNames[post.CategoryKind],
 | 
			
		||||
		Url:  BuildProjectMainCategoryUrl(project.Slug, post.CategoryKind),
 | 
			
		||||
		Name: ThreadTypeDisplayNames[thread.Type],
 | 
			
		||||
		Url:  BuildProjectRootResourceUrl(project.Slug, thread.Type),
 | 
			
		||||
	})
 | 
			
		||||
	switch post.CategoryKind {
 | 
			
		||||
	case models.CatKindForum:
 | 
			
		||||
		subforums := lineageBuilder.GetSubforumLineage(post.CategoryID)
 | 
			
		||||
		slugs := lineageBuilder.GetSubforumLineageSlugs(post.CategoryID)
 | 
			
		||||
	switch thread.Type {
 | 
			
		||||
	case models.ThreadTypeForumPost:
 | 
			
		||||
		subforums := lineageBuilder.GetSubforumLineage(*thread.SubforumID)
 | 
			
		||||
		slugs := lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID)
 | 
			
		||||
		for i, subforum := range subforums {
 | 
			
		||||
			result = append(result, templates.Breadcrumb{
 | 
			
		||||
				Name: *subforum.Name, // NOTE(asaf): All subforum categories must have names.
 | 
			
		||||
				Url:  hmnurl.BuildForumCategory(project.Slug, slugs[0:i+1], 1),
 | 
			
		||||
				Name: subforum.Name,
 | 
			
		||||
				Url:  hmnurl.BuildForum(project.Slug, slugs[0:i+1], 1),
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
	case models.CatKindLibraryResource:
 | 
			
		||||
		result = append(result, templates.Breadcrumb{
 | 
			
		||||
			Name: libraryResource.Name,
 | 
			
		||||
			Url:  hmnurl.BuildLibraryResource(project.Slug, libraryResource.ID),
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NOTE(asaf): THIS DOESN'T HANDLE WIKI EDIT ITEMS. Wiki edits are PostTextVersions, not Posts.
 | 
			
		||||
func MakePostListItem(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, thread *models.Thread, post *models.Post, user *models.User, libraryResource *models.LibraryResource, unread bool, includeBreadcrumbs bool, currentTheme string) templates.PostListItem {
 | 
			
		||||
func MakePostListItem(
 | 
			
		||||
	lineageBuilder *models.SubforumLineageBuilder,
 | 
			
		||||
	project *models.Project,
 | 
			
		||||
	thread *models.Thread,
 | 
			
		||||
	post *models.Post,
 | 
			
		||||
	user *models.User,
 | 
			
		||||
	unread bool,
 | 
			
		||||
	includeBreadcrumbs bool,
 | 
			
		||||
	currentTheme string,
 | 
			
		||||
) templates.PostListItem {
 | 
			
		||||
	var result templates.PostListItem
 | 
			
		||||
 | 
			
		||||
	result.Title = thread.Title
 | 
			
		||||
	result.User = templates.UserToTemplate(user, currentTheme)
 | 
			
		||||
	result.Date = post.PostDate
 | 
			
		||||
	result.Unread = unread
 | 
			
		||||
	libraryResourceId := 0
 | 
			
		||||
	if libraryResource != nil {
 | 
			
		||||
		libraryResourceId = libraryResource.ID
 | 
			
		||||
	}
 | 
			
		||||
	result.Url = UrlForGenericPost(post, lineageBuilder.GetSubforumLineageSlugs(post.CategoryID), thread.Title, libraryResourceId, project.Slug)
 | 
			
		||||
	result.Url = UrlForGenericPost(thread, post, lineageBuilder, project.Slug)
 | 
			
		||||
	result.Preview = post.Preview
 | 
			
		||||
 | 
			
		||||
	postType := templates.PostTypeUnknown
 | 
			
		||||
	postTypeOptions, found := PostTypeMap[post.CategoryKind]
 | 
			
		||||
	postTypeOptions, found := PostTypeMap[post.ThreadType]
 | 
			
		||||
	if found {
 | 
			
		||||
		var hasParent int
 | 
			
		||||
		if post.ParentID != nil {
 | 
			
		||||
| 
						 | 
				
			
			@ -104,7 +86,7 @@ func MakePostListItem(lineageBuilder *models.CategoryLineageBuilder, project *mo
 | 
			
		|||
	result.PostTypePrefix = PostTypePrefix[result.PostType]
 | 
			
		||||
 | 
			
		||||
	if includeBreadcrumbs {
 | 
			
		||||
		result.Breadcrumbs = PostBreadcrumbs(lineageBuilder, project, post, libraryResource)
 | 
			
		||||
		result.Breadcrumbs = PostBreadcrumbs(lineageBuilder, project, thread)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return result
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -339,9 +339,9 @@ func ProjectHomepage(c *RequestContext) ResponseData {
 | 
			
		|||
	}
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch category tree")
 | 
			
		||||
	categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
 | 
			
		||||
	lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch subforum tree")
 | 
			
		||||
	subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
 | 
			
		||||
	lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetching project timeline")
 | 
			
		||||
| 
						 | 
				
			
			@ -431,7 +431,6 @@ func ProjectHomepage(c *RequestContext) ResponseData {
 | 
			
		|||
			&post.(*postQuery).Post,
 | 
			
		||||
			&post.(*postQuery).Thread,
 | 
			
		||||
			project,
 | 
			
		||||
			nil,
 | 
			
		||||
			&post.(*postQuery).Author,
 | 
			
		||||
			c.Theme,
 | 
			
		||||
		))
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -156,8 +156,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
 | 
			
		|||
	mainRoutes.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
 | 
			
		||||
	mainRoutes.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit)))
 | 
			
		||||
	mainRoutes.GET(hmnurl.RegexForumThread, ForumThread)
 | 
			
		||||
	mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory)
 | 
			
		||||
	mainRoutes.POST(hmnurl.RegexForumCategoryMarkRead, authMiddleware(csrfMiddleware(ForumCategoryMarkRead)))
 | 
			
		||||
	mainRoutes.GET(hmnurl.RegexForum, Forum)
 | 
			
		||||
	mainRoutes.POST(hmnurl.RegexForumMarkRead, authMiddleware(csrfMiddleware(ForumMarkRead)))
 | 
			
		||||
	mainRoutes.GET(hmnurl.RegexForumPost, ForumPostRedirect)
 | 
			
		||||
	mainRoutes.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply))
 | 
			
		||||
	mainRoutes.POST(hmnurl.RegexForumPostReply, authMiddleware(csrfMiddleware(ForumPostReplySubmit)))
 | 
			
		||||
| 
						 | 
				
			
			@ -216,8 +216,7 @@ func getBaseData(c *RequestContext) templates.BaseData {
 | 
			
		|||
			ProjectHomepageUrl: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
 | 
			
		||||
			ProjectIndexUrl:    hmnurl.BuildProjectIndex(1),
 | 
			
		||||
			BlogUrl:            hmnurl.BuildBlog(c.CurrentProject.Slug, 1),
 | 
			
		||||
			ForumsUrl:          hmnurl.BuildForumCategory(c.CurrentProject.Slug, nil, 1),
 | 
			
		||||
			WikiUrl:            hmnurl.BuildWiki(c.CurrentProject.Slug),
 | 
			
		||||
			ForumsUrl:          hmnurl.BuildForum(c.CurrentProject.Slug, nil, 1),
 | 
			
		||||
			LibraryUrl:         hmnurl.BuildLibrary(c.CurrentProject.Slug),
 | 
			
		||||
			ManifestoUrl:       hmnurl.BuildManifesto(),
 | 
			
		||||
			EpisodeGuideUrl:    hmnurl.BuildHomepage(), // TODO(asaf)
 | 
			
		||||
| 
						 | 
				
			
			@ -231,7 +230,7 @@ func getBaseData(c *RequestContext) templates.BaseData {
 | 
			
		|||
			CodeOfConductUrl:           hmnurl.BuildCodeOfConduct(),
 | 
			
		||||
			CommunicationGuidelinesUrl: hmnurl.BuildCommunicationGuidelines(),
 | 
			
		||||
			ProjectIndexUrl:            hmnurl.BuildProjectIndex(1),
 | 
			
		||||
			ForumsUrl:                  hmnurl.BuildForumCategory(models.HMNProjectSlug, nil, 1),
 | 
			
		||||
			ForumsUrl:                  hmnurl.BuildForum(models.HMNProjectSlug, nil, 1),
 | 
			
		||||
			ContactUrl:                 hmnurl.BuildContactPage(),
 | 
			
		||||
			SitemapUrl:                 hmnurl.BuildSiteMap(),
 | 
			
		||||
		},
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -10,12 +10,10 @@ import (
 | 
			
		|||
	"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            }
 | 
			
		||||
	models.CatKindBlog:            {templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment},
 | 
			
		||||
	models.CatKindForum:           {templates.TimelineTypeForumThread, templates.TimelineTypeForumReply},
 | 
			
		||||
	models.CatKindWiki:            {templates.TimelineTypeWikiCreate, templates.TimelineTypeWikiTalk},
 | 
			
		||||
	models.CatKindLibraryResource: {templates.TimelineTypeLibraryComment, templates.TimelineTypeLibraryComment},
 | 
			
		||||
	models.ThreadTypeProjectArticle: {templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment},
 | 
			
		||||
	models.ThreadTypeForumPost:      {templates.TimelineTypeForumThread, templates.TimelineTypeForumReply},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var TimelineItemClassMap = map[templates.TimelineType]string{
 | 
			
		||||
| 
						 | 
				
			
			@ -27,12 +25,6 @@ var TimelineItemClassMap = map[templates.TimelineType]string{
 | 
			
		|||
	templates.TimelineTypeBlogPost:    "blogs",
 | 
			
		||||
	templates.TimelineTypeBlogComment: "blogs",
 | 
			
		||||
 | 
			
		||||
	templates.TimelineTypeWikiCreate: "wiki",
 | 
			
		||||
	templates.TimelineTypeWikiEdit:   "wiki",
 | 
			
		||||
	templates.TimelineTypeWikiTalk:   "wiki",
 | 
			
		||||
 | 
			
		||||
	templates.TimelineTypeLibraryComment: "library",
 | 
			
		||||
 | 
			
		||||
	templates.TimelineTypeSnippetImage:   "snippets",
 | 
			
		||||
	templates.TimelineTypeSnippetVideo:   "snippets",
 | 
			
		||||
	templates.TimelineTypeSnippetAudio:   "snippets",
 | 
			
		||||
| 
						 | 
				
			
			@ -48,21 +40,15 @@ var TimelineTypeTitleMap = map[templates.TimelineType]string{
 | 
			
		|||
	templates.TimelineTypeBlogPost:    "New blog post",
 | 
			
		||||
	templates.TimelineTypeBlogComment: "Blog comment",
 | 
			
		||||
 | 
			
		||||
	templates.TimelineTypeWikiCreate: "New wiki article",
 | 
			
		||||
	templates.TimelineTypeWikiEdit:   "Wiki edit",
 | 
			
		||||
	templates.TimelineTypeWikiTalk:   "Wiki talk",
 | 
			
		||||
 | 
			
		||||
	templates.TimelineTypeLibraryComment: "Library comment",
 | 
			
		||||
 | 
			
		||||
	templates.TimelineTypeSnippetImage:   "Snippet",
 | 
			
		||||
	templates.TimelineTypeSnippetVideo:   "Snippet",
 | 
			
		||||
	templates.TimelineTypeSnippetAudio:   "Snippet",
 | 
			
		||||
	templates.TimelineTypeSnippetYoutube: "Snippet",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func PostToTimelineItem(lineageBuilder *models.CategoryLineageBuilder, post *models.Post, thread *models.Thread, project *models.Project, libraryResource *models.LibraryResource, owner *models.User, currentTheme string) templates.TimelineItem {
 | 
			
		||||
func PostToTimelineItem(lineageBuilder *models.SubforumLineageBuilder, post *models.Post, thread *models.Thread, project *models.Project, owner *models.User, currentTheme string) templates.TimelineItem {
 | 
			
		||||
	itemType := templates.TimelineTypeUnknown
 | 
			
		||||
	typeByCatKind, found := TimelineTypeMap[post.CategoryKind]
 | 
			
		||||
	typeByCatKind, found := TimelineTypeMap[post.ThreadType]
 | 
			
		||||
	if found {
 | 
			
		||||
		hasParent := 0
 | 
			
		||||
		if post.ParentID != nil {
 | 
			
		||||
| 
						 | 
				
			
			@ -71,17 +57,12 @@ func PostToTimelineItem(lineageBuilder *models.CategoryLineageBuilder, post *mod
 | 
			
		|||
		itemType = typeByCatKind[hasParent]
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	libraryResourceId := 0
 | 
			
		||||
	if libraryResource != nil {
 | 
			
		||||
		libraryResourceId = libraryResource.ID
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return templates.TimelineItem{
 | 
			
		||||
		Type:      itemType,
 | 
			
		||||
		TypeTitle: TimelineTypeTitleMap[itemType],
 | 
			
		||||
		Class:     TimelineItemClassMap[itemType],
 | 
			
		||||
		Date:      post.PostDate,
 | 
			
		||||
		Url:       UrlForGenericPost(post, lineageBuilder.GetSubforumLineageSlugs(post.CategoryID), thread.Title, libraryResourceId, project.Slug),
 | 
			
		||||
		Url:       UrlForGenericPost(thread, post, lineageBuilder, project.Slug),
 | 
			
		||||
 | 
			
		||||
		OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
 | 
			
		||||
		OwnerName:      templates.UserDisplayName(owner),
 | 
			
		||||
| 
						 | 
				
			
			@ -89,25 +70,7 @@ func PostToTimelineItem(lineageBuilder *models.CategoryLineageBuilder, post *mod
 | 
			
		|||
		Description:    "", // NOTE(asaf): No description for posts
 | 
			
		||||
 | 
			
		||||
		Title:       thread.Title,
 | 
			
		||||
		Breadcrumbs: PostBreadcrumbs(lineageBuilder, project, post, libraryResource),
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func PostVersionToWikiTimelineItem(lineageBuilder *models.CategoryLineageBuilder, version *models.PostVersion, post *models.Post, thread *models.Thread, project *models.Project, owner *models.User, currentTheme string) templates.TimelineItem {
 | 
			
		||||
	return templates.TimelineItem{
 | 
			
		||||
		Type:      templates.TimelineTypeWikiEdit,
 | 
			
		||||
		TypeTitle: TimelineTypeTitleMap[templates.TimelineTypeWikiEdit],
 | 
			
		||||
		Class:     TimelineItemClassMap[templates.TimelineTypeWikiEdit],
 | 
			
		||||
		Date:      version.Date,
 | 
			
		||||
		Url:       hmnurl.BuildWikiArticle(project.Slug, thread.ID, thread.Title),
 | 
			
		||||
 | 
			
		||||
		OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
 | 
			
		||||
		OwnerName:      templates.UserDisplayName(owner),
 | 
			
		||||
		OwnerUrl:       hmnurl.BuildUserProfile(owner.Username),
 | 
			
		||||
		Description:    "", // NOTE(asaf): No description for posts
 | 
			
		||||
 | 
			
		||||
		Title:       thread.Title,
 | 
			
		||||
		Breadcrumbs: PostBreadcrumbs(lineageBuilder, project, post, nil),
 | 
			
		||||
		Breadcrumbs: PostBreadcrumbs(lineageBuilder, project, thread),
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,16 +5,12 @@ import (
 | 
			
		|||
	"git.handmade.network/hmn/hmn/src/models"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func BuildProjectMainCategoryUrl(projectSlug string, kind models.CategoryKind) string {
 | 
			
		||||
func BuildProjectRootResourceUrl(projectSlug string, kind models.ThreadType) string {
 | 
			
		||||
	switch kind {
 | 
			
		||||
	case models.CatKindBlog:
 | 
			
		||||
	case models.ThreadTypeProjectArticle:
 | 
			
		||||
		return hmnurl.BuildBlog(projectSlug, 1)
 | 
			
		||||
	case models.CatKindForum:
 | 
			
		||||
		return hmnurl.BuildForumCategory(projectSlug, nil, 1)
 | 
			
		||||
	case models.CatKindWiki:
 | 
			
		||||
		return hmnurl.BuildWiki(projectSlug)
 | 
			
		||||
	case models.CatKindLibraryResource:
 | 
			
		||||
		return hmnurl.BuildLibrary(projectSlug)
 | 
			
		||||
	case models.ThreadTypeForumPost:
 | 
			
		||||
		return hmnurl.BuildForum(projectSlug, nil, 1)
 | 
			
		||||
	}
 | 
			
		||||
	return hmnurl.BuildProjectHomepage(projectSlug)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -20,8 +20,6 @@ type UserProfileTemplateData struct {
 | 
			
		|||
	TimelineItems       []templates.TimelineItem
 | 
			
		||||
	NumForums           int
 | 
			
		||||
	NumBlogs            int
 | 
			
		||||
	NumWiki             int
 | 
			
		||||
	NumLibrary          int
 | 
			
		||||
	NumSnippets         int
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -117,7 +115,6 @@ func UserProfile(c *RequestContext) ResponseData {
 | 
			
		|||
	type postQuery struct {
 | 
			
		||||
		Post    models.Post    `db:"post"`
 | 
			
		||||
		Thread  models.Thread  `db:"thread"`
 | 
			
		||||
		LibraryResource *models.LibraryResource `db:"lib_resource"`
 | 
			
		||||
		Project models.Project `db:"project"`
 | 
			
		||||
	}
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch posts")
 | 
			
		||||
| 
						 | 
				
			
			@ -128,7 +125,6 @@ func UserProfile(c *RequestContext) ResponseData {
 | 
			
		|||
			handmade_post AS post
 | 
			
		||||
			INNER JOIN handmade_thread AS thread ON thread.id = post.thread_id
 | 
			
		||||
			INNER JOIN handmade_project AS project ON project.id = post.project_id
 | 
			
		||||
			LEFT JOIN handmade_libraryresource AS lib_resource ON lib_resource.category_id = post.category_id
 | 
			
		||||
		WHERE
 | 
			
		||||
			post.author_id = $1
 | 
			
		||||
			AND project.lifecycle = ANY ($2)
 | 
			
		||||
| 
						 | 
				
			
			@ -142,37 +138,6 @@ func UserProfile(c *RequestContext) ResponseData {
 | 
			
		|||
	postQuerySlice := postQueryResult.ToSlice()
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	type wikiEditQuery struct {
 | 
			
		||||
		PostVersion models.PostVersion `db:"version"`
 | 
			
		||||
		Post        models.Post        `db:"post"`
 | 
			
		||||
		Thread      models.Thread      `db:"thread"`
 | 
			
		||||
		Project     models.Project     `db:"project"`
 | 
			
		||||
	}
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch wiki edits")
 | 
			
		||||
	wikiEditQueryResult, err := db.Query(c.Context(), c.Conn, wikiEditQuery{},
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
			handmade_postversion AS version
 | 
			
		||||
			INNER JOIN handmade_post AS post ON post.id = version.post_id
 | 
			
		||||
			INNER JOIN handmade_thread AS thread on thread.id = post.thread_id
 | 
			
		||||
			INNER JOIN handmade_project AS project ON project.id = post.project_id
 | 
			
		||||
		WHERE
 | 
			
		||||
			version.editor_id = $1
 | 
			
		||||
			AND post.parent_id IS NULL
 | 
			
		||||
			AND post.category_kind = $2
 | 
			
		||||
			AND project.lifecycle = ANY ($3)
 | 
			
		||||
		`,
 | 
			
		||||
		profileUser.ID,
 | 
			
		||||
		models.CatKindWiki,
 | 
			
		||||
		models.VisibleProjectLifecycles,
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch wiki edits for user: %s", username))
 | 
			
		||||
	}
 | 
			
		||||
	wikiEditQuerySlice := wikiEditQueryResult.ToSlice()
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	type snippetQuery struct {
 | 
			
		||||
		Snippet        models.Snippet         `db:"snippet"`
 | 
			
		||||
		Asset          *models.Asset          `db:"asset"`
 | 
			
		||||
| 
						 | 
				
			
			@ -197,17 +162,15 @@ func UserProfile(c *RequestContext) ResponseData {
 | 
			
		|||
	snippetQuerySlice := snippetQueryResult.ToSlice()
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch category tree")
 | 
			
		||||
	categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
 | 
			
		||||
	lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch subforum tree")
 | 
			
		||||
	subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
 | 
			
		||||
	lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("PROFILE", "Construct timeline items")
 | 
			
		||||
	timelineItems := make([]templates.TimelineItem, 0, len(postQuerySlice)+len(wikiEditQuerySlice)+len(snippetQuerySlice))
 | 
			
		||||
	timelineItems := make([]templates.TimelineItem, 0, len(postQuerySlice)+len(snippetQuerySlice))
 | 
			
		||||
	numForums := 0
 | 
			
		||||
	numBlogs := 0
 | 
			
		||||
	numWiki := len(wikiEditQuerySlice)
 | 
			
		||||
	numLibrary := 0
 | 
			
		||||
	numSnippets := len(snippetQuerySlice)
 | 
			
		||||
 | 
			
		||||
	for _, postRow := range postQuerySlice {
 | 
			
		||||
| 
						 | 
				
			
			@ -217,7 +180,6 @@ func UserProfile(c *RequestContext) ResponseData {
 | 
			
		|||
			&postData.Post,
 | 
			
		||||
			&postData.Thread,
 | 
			
		||||
			&postData.Project,
 | 
			
		||||
			postData.LibraryResource,
 | 
			
		||||
			profileUser,
 | 
			
		||||
			c.Theme,
 | 
			
		||||
		)
 | 
			
		||||
| 
						 | 
				
			
			@ -226,10 +188,6 @@ func UserProfile(c *RequestContext) ResponseData {
 | 
			
		|||
			numForums += 1
 | 
			
		||||
		case templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment:
 | 
			
		||||
			numBlogs += 1
 | 
			
		||||
		case templates.TimelineTypeWikiCreate, templates.TimelineTypeWikiTalk:
 | 
			
		||||
			numWiki += 1
 | 
			
		||||
		case templates.TimelineTypeLibraryComment:
 | 
			
		||||
			numLibrary += 1
 | 
			
		||||
		}
 | 
			
		||||
		if timelineItem.Type != templates.TimelineTypeUnknown {
 | 
			
		||||
			timelineItems = append(timelineItems, timelineItem)
 | 
			
		||||
| 
						 | 
				
			
			@ -238,20 +196,6 @@ func UserProfile(c *RequestContext) ResponseData {
 | 
			
		|||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, wikiEditRow := range wikiEditQuerySlice {
 | 
			
		||||
		wikiEditData := wikiEditRow.(*wikiEditQuery)
 | 
			
		||||
		timelineItem := PostVersionToWikiTimelineItem(
 | 
			
		||||
			lineageBuilder,
 | 
			
		||||
			&wikiEditData.PostVersion,
 | 
			
		||||
			&wikiEditData.Post,
 | 
			
		||||
			&wikiEditData.Thread,
 | 
			
		||||
			&wikiEditData.Project,
 | 
			
		||||
			profileUser,
 | 
			
		||||
			c.Theme,
 | 
			
		||||
		)
 | 
			
		||||
		timelineItems = append(timelineItems, timelineItem)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, snippetRow := range snippetQuerySlice {
 | 
			
		||||
		snippetData := snippetRow.(*snippetQuery)
 | 
			
		||||
		timelineItem := SnippetToTimelineItem(
 | 
			
		||||
| 
						 | 
				
			
			@ -282,8 +226,6 @@ func UserProfile(c *RequestContext) ResponseData {
 | 
			
		|||
		TimelineItems:       timelineItems,
 | 
			
		||||
		NumForums:           numForums,
 | 
			
		||||
		NumBlogs:            numBlogs,
 | 
			
		||||
		NumWiki:             numWiki,
 | 
			
		||||
		NumLibrary:          numLibrary,
 | 
			
		||||
		NumSnippets:         numSnippets,
 | 
			
		||||
	}, c.Perf)
 | 
			
		||||
	return res
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue