URL rework!

This commit is contained in:
Asaf Gartner 2021-05-12 01:53:23 +03:00
parent 4e946cd476
commit 02938bbf2c
22 changed files with 1389 additions and 503 deletions

View File

@ -1,7 +1,9 @@
package hmnurl package hmnurl
import ( import (
"fmt"
"net/url" "net/url"
"time"
"git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/models"
@ -17,9 +19,11 @@ type Q struct {
} }
var baseUrlParsed url.URL var baseUrlParsed url.URL
var cacheBust string
func init() { func init() {
SetGlobalBaseUrl(config.Config.BaseUrl) SetGlobalBaseUrl(config.Config.BaseUrl)
SetCacheBust(fmt.Sprint(time.Now().Unix()))
} }
func SetGlobalBaseUrl(fullBaseUrl string) { func SetGlobalBaseUrl(fullBaseUrl string) {
@ -34,11 +38,19 @@ func SetGlobalBaseUrl(fullBaseUrl string) {
baseUrlParsed = *parsed baseUrlParsed = *parsed
} }
func SetCacheBust(newCacheBust string) {
cacheBust = newCacheBust
}
func Url(path string, query []Q) string { func Url(path string, query []Q) string {
return ProjectUrl(path, query, "") return ProjectUrl(path, query, "")
} }
func ProjectUrl(path string, query []Q, slug string) string { func ProjectUrl(path string, query []Q, slug string) string {
return ProjectUrlWithFragment(path, query, slug, "")
}
func ProjectUrlWithFragment(path string, query []Q, slug string, fragment string) string {
subdomain := slug subdomain := slug
if slug == models.HMNProjectSlug { if slug == models.HMNProjectSlug {
subdomain = "" subdomain = ""
@ -54,6 +66,7 @@ func ProjectUrl(path string, query []Q, slug string) string {
Host: host, Host: host,
Path: trim(path), Path: trim(path),
RawQuery: encodeQuery(query), RawQuery: encodeQuery(query),
Fragment: fragment,
} }
return url.String() return url.String()

View File

@ -27,14 +27,32 @@ func TestUrl(t *testing.T) {
func TestHomepage(t *testing.T) { func TestHomepage(t *testing.T) {
AssertRegexMatch(t, BuildHomepage(), RegexHomepage, nil) AssertRegexMatch(t, BuildHomepage(), RegexHomepage, nil)
AssertRegexMatch(t, BuildProjectHomepage("hero"), RegexHomepage, nil)
AssertSubdomain(t, BuildProjectHomepage("hero"), "hero")
} }
func TestLogin(t *testing.T) { func TestProjectIndex(t *testing.T) {
AssertRegexMatch(t, BuildLogin(), RegexLogin, nil) AssertRegexMatch(t, BuildProjectIndex(), RegexProjectIndex, nil)
} }
func TestLogout(t *testing.T) { func TestSiteMap(t *testing.T) {
AssertRegexMatch(t, BuildLogout(), RegexLogout, nil) AssertRegexMatch(t, BuildSiteMap(), RegexSiteMap, nil)
}
func TestAtomFeed(t *testing.T) {
AssertRegexMatch(t, BuildAtomFeed(), RegexAtomFeed, nil)
}
func TestLoginAction(t *testing.T) {
AssertRegexMatch(t, BuildLoginAction(""), RegexLoginAction, nil)
}
func TestLoginPage(t *testing.T) {
AssertRegexMatch(t, BuildLoginPage(""), RegexLoginPage, nil)
}
func TestLogoutAction(t *testing.T) {
AssertRegexMatch(t, BuildLogoutAction(), RegexLogoutAction, nil)
} }
func TestStaticPages(t *testing.T) { func TestStaticPages(t *testing.T) {
@ -47,6 +65,10 @@ func TestStaticPages(t *testing.T) {
AssertRegexMatch(t, BuildProjectSubmissionGuidelines(), RegexProjectSubmissionGuidelines, nil) AssertRegexMatch(t, BuildProjectSubmissionGuidelines(), RegexProjectSubmissionGuidelines, nil)
} }
func TestMember(t *testing.T) {
AssertRegexMatch(t, BuildMember("test"), RegexMember, map[string]string{"member": "test"})
}
func TestFeed(t *testing.T) { func TestFeed(t *testing.T) {
AssertRegexMatch(t, BuildFeed(), RegexFeed, nil) AssertRegexMatch(t, BuildFeed(), RegexFeed, nil)
assert.Equal(t, BuildFeed(), BuildFeedWithPage(1)) assert.Equal(t, BuildFeed(), BuildFeedWithPage(1))
@ -57,30 +79,236 @@ func TestFeed(t *testing.T) {
assert.Panics(t, func() { BuildFeedWithPage(0) }) assert.Panics(t, func() { BuildFeedWithPage(0) })
} }
func TestForumThread(t *testing.T) {
AssertRegexMatch(t, BuildForumThread("", nil, 1, 1), RegexForumThread, map[string]string{"threadid": "1"})
AssertRegexMatch(t, BuildForumThread("", []string{"wip"}, 1, 2), RegexForumThread, map[string]string{"cats": "forums/wip", "page": "2", "threadid": "1"})
AssertRegexMatch(t, BuildForumThread("", []string{"sub", "wip"}, 1, 2), RegexForumThread, map[string]string{"cats": "forums/sub/wip", "page": "2", "threadid": "1"})
AssertSubdomain(t, BuildForumThread("hmn", nil, 1, 1), "")
AssertSubdomain(t, BuildForumThread("", nil, 1, 1), "")
AssertSubdomain(t, BuildForumThread("hero", nil, 1, 1), "hero")
assert.Panics(t, func() { BuildForumThread("", []string{"", "wip"}, 1, 1) })
assert.Panics(t, func() { BuildForumThread("", []string{" ", "wip"}, 1, 1) })
assert.Panics(t, func() { BuildForumThread("", []string{"wip/jobs"}, 1, 1) })
}
func TestForumCategory(t *testing.T) { func TestForumCategory(t *testing.T) {
AssertRegexMatch(t, BuildForumCategory("", nil, 1), RegexForumCategory, nil) AssertRegexMatch(t, BuildForumCategory("", nil, 1), RegexForumCategory, nil)
AssertRegexMatch(t, BuildForumCategory("", []string{"wip"}, 2), RegexForumCategory, map[string]string{"cats": "forums/wip", "page": "2"}) 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": "forums/sub/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("hmn", nil, 1), "")
AssertSubdomain(t, BuildForumCategory("", nil, 1), "") AssertSubdomain(t, BuildForumCategory("", nil, 1), "")
AssertSubdomain(t, BuildForumCategory("hero", nil, 1), "hero") 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"}, 1) }) assert.Panics(t, func() { BuildForumCategory("", []string{" ", "wip"}, 1) })
assert.Panics(t, func() { BuildForumCategory("", []string{"wip/jobs"}, 1) }) assert.Panics(t, func() { BuildForumCategory("", []string{"wip/jobs"}, 1) })
} }
func TestForumNewThread(t *testing.T) {
AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}), RegexForumNewThread, map[string]string{"cats": "sub/wip"})
}
func TestForumThread(t *testing.T) {
AssertRegexMatch(t, BuildForumThread("", nil, 1, "", 1), RegexForumThread, map[string]string{"threadid": "1"})
AssertRegexMatch(t, BuildForumThread("", nil, 1, "thread/title/123http://", 2), RegexForumThread, map[string]string{"threadid": "1", "page": "2"})
AssertRegexMatch(t, BuildForumThreadWithPostHash("", nil, 1, "thread/title/123http://", 2, 123), RegexForumThread, map[string]string{"threadid": "1", "page": "2"})
AssertSubdomain(t, BuildForumThread("hero", nil, 1, "", 1), "hero")
assert.Panics(t, func() { BuildForumThread("", nil, -1, "", 1) })
assert.Panics(t, func() { BuildForumThread("", nil, 1, "", -1) })
}
func TestForumPost(t *testing.T) {
AssertRegexMatch(t, BuildForumPost("", nil, 1, 2), RegexForumPost, map[string]string{"threadid": "1", "postid": "2"})
AssertRegexNoMatch(t, BuildForumPost("", nil, 1, 2), RegexForumThread)
AssertSubdomain(t, BuildForumPost("hero", nil, 1, 2), "hero")
assert.Panics(t, func() { BuildForumPost("", nil, 1, -1) })
}
func TestForumPostDelete(t *testing.T) {
AssertRegexMatch(t, BuildForumPostDelete("", nil, 1, 2), RegexForumPostDelete, map[string]string{"threadid": "1", "postid": "2"})
AssertRegexNoMatch(t, BuildForumPostDelete("", nil, 1, 2), RegexForumPost)
AssertSubdomain(t, BuildForumPostDelete("hero", nil, 1, 2), "hero")
}
func TestForumPostEdit(t *testing.T) {
AssertRegexMatch(t, BuildForumPostEdit("", nil, 1, 2), RegexForumPostEdit, map[string]string{"threadid": "1", "postid": "2"})
AssertRegexNoMatch(t, BuildForumPostEdit("", nil, 1, 2), RegexForumPost)
AssertSubdomain(t, BuildForumPostEdit("hero", nil, 1, 2), "hero")
}
func TestForumPostReply(t *testing.T) {
AssertRegexMatch(t, BuildForumPostReply("", nil, 1, 2), RegexForumPostReply, map[string]string{"threadid": "1", "postid": "2"})
AssertRegexNoMatch(t, BuildForumPostReply("", nil, 1, 2), RegexForumPost)
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"})
AssertSubdomain(t, BuildBlog("hero", 1), "hero")
}
func TestBlogThread(t *testing.T) {
AssertRegexMatch(t, BuildBlogThread("", 1, "", 1), RegexBlogThread, map[string]string{"threadid": "1"})
AssertRegexMatch(t, BuildBlogThread("", 1, "", 2), RegexBlogThread, map[string]string{"threadid": "1", "page": "2"})
AssertRegexMatch(t, BuildBlogThread("", 1, "title/bla/http://", 2), RegexBlogThread, map[string]string{"threadid": "1", "page": "2"})
AssertRegexMatch(t, BuildBlogThreadWithPostHash("", 1, "title/bla/http://", 2, 123), RegexBlogThread, map[string]string{"threadid": "1", "page": "2"})
AssertRegexNoMatch(t, BuildBlogThread("", 1, "", 2), RegexBlog)
AssertSubdomain(t, BuildBlogThread("hero", 1, "", 1), "hero")
}
func TestBlogPost(t *testing.T) {
AssertRegexMatch(t, BuildBlogPost("", 1, 2), RegexBlogPost, map[string]string{"threadid": "1", "postid": "2"})
AssertRegexNoMatch(t, BuildBlogPost("", 1, 2), RegexBlogThread)
AssertSubdomain(t, BuildBlogPost("hero", 1, 2), "hero")
}
func TestBlogPostDelete(t *testing.T) {
AssertRegexMatch(t, BuildBlogPostDelete("", 1, 2), RegexBlogPostDelete, map[string]string{"threadid": "1", "postid": "2"})
AssertRegexNoMatch(t, BuildBlogPostDelete("", 1, 2), RegexBlogPost)
AssertSubdomain(t, BuildBlogPostDelete("hero", 1, 2), "hero")
}
func TestBlogPostEdit(t *testing.T) {
AssertRegexMatch(t, BuildBlogPostEdit("", 1, 2), RegexBlogPostEdit, map[string]string{"threadid": "1", "postid": "2"})
AssertRegexNoMatch(t, BuildBlogPostEdit("", 1, 2), RegexBlogPost)
AssertSubdomain(t, BuildBlogPostEdit("hero", 1, 2), "hero")
}
func TestBlogPostReply(t *testing.T) {
AssertRegexMatch(t, BuildBlogPostReply("", 1, 2), RegexBlogPostReply, map[string]string{"threadid": "1", "postid": "2"})
AssertRegexNoMatch(t, BuildBlogPostReply("", 1, 2), RegexBlogPost)
AssertSubdomain(t, BuildBlogPostReply("hero", 1, 2), "hero")
}
func TestBlogPostQuote(t *testing.T) {
AssertRegexMatch(t, BuildBlogPostQuote("", 1, 2), RegexBlogPostQuote, map[string]string{"threadid": "1", "postid": "2"})
AssertRegexNoMatch(t, BuildBlogPostQuote("", 1, 2), RegexBlogPost)
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")
}
func TestLibraryAll(t *testing.T) {
AssertRegexMatch(t, BuildLibraryAll(""), RegexLibraryAll, nil)
AssertSubdomain(t, BuildLibraryAll("hero"), "hero")
}
func TestLibraryTopic(t *testing.T) {
AssertRegexMatch(t, BuildLibraryTopic("", 1), RegexLibraryTopic, map[string]string{"topicid": "1"})
AssertSubdomain(t, BuildLibraryTopic("hero", 1), "hero")
}
func TestLibraryResource(t *testing.T) {
AssertRegexMatch(t, BuildLibraryResource("", 1), RegexLibraryResource, map[string]string{"resourceid": "1"})
AssertSubdomain(t, BuildLibraryResource("hero", 1), "hero")
}
func TestLibraryDiscussion(t *testing.T) {
AssertRegexMatch(t, BuildLibraryDiscussion("", 1, 2, 1), RegexLibraryDiscussion, map[string]string{"resourceid": "1", "threadid": "2"})
AssertRegexMatch(t, BuildLibraryDiscussion("", 1, 2, 3), RegexLibraryDiscussion, map[string]string{"resourceid": "1", "threadid": "2", "page": "3"})
AssertRegexMatch(t, BuildLibraryDiscussionWithPostHash("", 1, 2, 3, 123), RegexLibraryDiscussion, map[string]string{"resourceid": "1", "threadid": "2", "page": "3"})
AssertSubdomain(t, BuildLibraryDiscussion("hero", 1, 2, 3), "hero")
}
func TestLibraryPost(t *testing.T) {
AssertRegexMatch(t, BuildLibraryPost("", 1, 2, 3), RegexLibraryPost, map[string]string{"resourceid": "1", "threadid": "2", "postid": "3"})
AssertSubdomain(t, BuildLibraryPost("hero", 1, 2, 3), "hero")
}
func TestLibraryPostDelete(t *testing.T) {
AssertRegexMatch(t, BuildLibraryPostDelete("", 1, 2, 3), RegexLibraryPostDelete, map[string]string{"resourceid": "1", "threadid": "2", "postid": "3"})
AssertSubdomain(t, BuildLibraryPostDelete("hero", 1, 2, 3), "hero")
}
func TestLibraryPostEdit(t *testing.T) {
AssertRegexMatch(t, BuildLibraryPostEdit("", 1, 2, 3), RegexLibraryPostEdit, map[string]string{"resourceid": "1", "threadid": "2", "postid": "3"})
AssertSubdomain(t, BuildLibraryPostEdit("hero", 1, 2, 3), "hero")
}
func TestLibraryPostReply(t *testing.T) {
AssertRegexMatch(t, BuildLibraryPostReply("", 1, 2, 3), RegexLibraryPostReply, map[string]string{"resourceid": "1", "threadid": "2", "postid": "3"})
AssertSubdomain(t, BuildLibraryPostReply("hero", 1, 2, 3), "hero")
}
func TestLibraryPostQuote(t *testing.T) {
AssertRegexMatch(t, BuildLibraryPostQuote("", 1, 2, 3), RegexLibraryPostQuote, map[string]string{"resourceid": "1", "threadid": "2", "postid": "3"})
AssertSubdomain(t, BuildLibraryPostQuote("hero", 1, 2, 3), "hero")
}
func TestProjectCSS(t *testing.T) { func TestProjectCSS(t *testing.T) {
AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil) AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil)
} }
@ -96,6 +324,10 @@ func TestPublic(t *testing.T) {
assert.Panics(t, func() { BuildPublic("/thing/ /image.png") }) assert.Panics(t, func() { BuildPublic("/thing/ /image.png") })
} }
func TestMarkRead(t *testing.T) {
AssertRegexMatch(t, BuildMarkRead(5), RegexMarkRead, map[string]string{"catid": "5"})
}
func AssertSubdomain(t *testing.T, fullUrl string, expectedSubdomain string) { func AssertSubdomain(t *testing.T, fullUrl string, expectedSubdomain string) {
parsed, err := url.Parse(fullUrl) parsed, err := url.Parse(fullUrl)
ok := assert.Nilf(t, err, "Full url could not be parsed: %s", fullUrl) ok := assert.Nilf(t, err, "Full url could not be parsed: %s", fullUrl)
@ -144,3 +376,18 @@ func AssertRegexMatch(t *testing.T, fullUrl string, regex *regexp.Regexp, params
} }
} }
} }
func AssertRegexNoMatch(t *testing.T, fullUrl string, regex *regexp.Regexp) {
parsed, err := url.Parse(fullUrl)
ok := assert.Nilf(t, err, "Full url could not be parsed: %s", fullUrl)
if !ok {
return
}
requestPath := parsed.Path
if len(requestPath) == 0 {
requestPath = "/"
}
match := regex.FindStringSubmatch(requestPath)
assert.Nilf(t, match, "Url matched regex: [%s] vs [%s]", requestPath, regex.String())
}

View File

@ -1,6 +1,7 @@
package hmnurl package hmnurl
import ( import (
"net/url"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@ -8,24 +9,70 @@ import (
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
) )
// TODO(asaf): Make this whole file only crash in Dev
var RegexHomepage = regexp.MustCompile("^/$") var RegexHomepage = regexp.MustCompile("^/$")
func BuildHomepage() string { func BuildHomepage() string {
return Url("/", nil) return Url("/", nil)
} }
var RegexLogin = regexp.MustCompile("^/login$") func BuildProjectHomepage(projectSlug string) string {
return ProjectUrl("/", nil, projectSlug)
func BuildLogin() string {
return Url("/login", nil)
} }
var RegexLogout = regexp.MustCompile("^/logout$") var RegexProjectIndex = regexp.MustCompile("^/projects$")
func BuildLogout() string { func BuildProjectIndex() string {
return Url("/projects", nil)
}
var RegexShowcase = regexp.MustCompile("^/showcase$")
func BuildShowcase() string {
return Url("/showcase", nil)
}
var RegexStreams = regexp.MustCompile("^/streams$")
func BuildStreams() string {
return Url("/streams", nil)
}
var RegexSiteMap = regexp.MustCompile("^/sitemap$")
func BuildSiteMap() string {
return Url("/sitemap", nil)
}
var RegexAtomFeed = regexp.MustCompile("^/atom$")
func BuildAtomFeed() string {
return Url("/atom", nil)
}
var RegexLoginAction = regexp.MustCompile("^/login$")
func BuildLoginAction(redirectTo string) string {
return Url("/login", []Q{{Name: "redirect", Value: redirectTo}})
}
var RegexLoginPage = regexp.MustCompile("^/_login$")
func BuildLoginPage(redirectTo string) string {
return Url("/_login", []Q{{Name: "redirect", Value: redirectTo}})
}
var RegexLogoutAction = regexp.MustCompile("^/logout$")
func BuildLogoutAction() string {
return Url("/logout", nil) return Url("/logout", nil)
} }
/*
* Static Pages
*/
var RegexManifesto = regexp.MustCompile("^/manifesto$") var RegexManifesto = regexp.MustCompile("^/manifesto$")
func BuildManifesto() string { func BuildManifesto() string {
@ -68,6 +115,23 @@ func BuildProjectSubmissionGuidelines() string {
return Url("/project-guidelines", nil) return Url("/project-guidelines", nil)
} }
/*
* Member
*/
var RegexMember = regexp.MustCompile(`^/m/(?P<member>[^/]+)$`)
func BuildMember(username string) string {
if len(username) == 0 {
panic(oops.New(nil, "Username must not be blank"))
}
return Url("/m/"+username, nil)
}
/*
* Feed
*/
var RegexFeed = regexp.MustCompile(`^/feed(/(?P<page>.+)?)?$`) var RegexFeed = regexp.MustCompile(`^/feed(/(?P<page>.+)?)?$`)
func BuildFeed() string { func BuildFeed() string {
@ -84,56 +148,31 @@ func BuildFeedWithPage(page int) string {
return Url("/feed/"+strconv.Itoa(page), nil) return Url("/feed/"+strconv.Itoa(page), nil)
} }
var RegexForumThread = regexp.MustCompile(`^/(?P<cats>forums(/[^\d]+?)*)/t/(?P<threadid>\d+)(/(?P<page>\d+))?$`) /*
* Podcast
*/
func BuildForumThread(projectSlug string, subforums []string, threadId int, page int) string { var RegexPodcast = regexp.MustCompile(`^/podcast$`)
if page < 1 {
panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page))
}
var builder strings.Builder func BuildPodcast(projectSlug string) string {
builder.WriteString("/forums") return ProjectUrl("/podcast", nil, projectSlug)
for _, subforum := range subforums {
subforum = strings.TrimSpace(subforum)
if strings.Contains(subforum, "/") {
panic(oops.New(nil, "Tried building forum thread url with / in subforum name"))
}
if len(subforum) == 0 {
panic(oops.New(nil, "Tried building forum thread url with blank subforum"))
}
builder.WriteRune('/')
builder.WriteString(subforum)
}
builder.WriteString("/t/")
builder.WriteString(strconv.Itoa(threadId))
if page > 1 {
builder.WriteRune('/')
builder.WriteString(strconv.Itoa(page))
}
return ProjectUrl(builder.String(), nil, projectSlug)
} }
var RegexForumCategory = regexp.MustCompile(`^/(?P<cats>forums(/[^\d]+?)*)(/(?P<page>\d+))?$`) /*
* 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 be enforce it here?
var RegexForumCategory = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?(/(?P<page>\d+))?$`)
func BuildForumCategory(projectSlug string, subforums []string, page int) string { func BuildForumCategory(projectSlug string, subforums []string, page int) string {
if page < 1 { if page < 1 {
panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page)) panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page))
} }
var builder strings.Builder builder := buildForumCategoryPath(subforums)
builder.WriteString("/forums")
for _, subforum := range subforums {
subforum = strings.TrimSpace(subforum)
if strings.Contains(subforum, "/") {
panic(oops.New(nil, "Tried building forum thread url with / in subforum name"))
}
if len(subforum) == 0 {
panic(oops.New(nil, "Tried building forum thread url with blank subforum"))
}
builder.WriteRune('/')
builder.WriteString(subforum)
}
if page > 1 { if page > 1 {
builder.WriteRune('/') builder.WriteRune('/')
builder.WriteString(strconv.Itoa(page)) builder.WriteString(strconv.Itoa(page))
@ -142,55 +181,387 @@ func BuildForumCategory(projectSlug string, subforums []string, page int) string
return ProjectUrl(builder.String(), nil, projectSlug) return ProjectUrl(builder.String(), nil, projectSlug)
} }
var RegexForumPost = regexp.MustCompile(``) // TODO(asaf): Complete this and test it var RegexForumNewThread = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/new?$`)
func BuildForumPost(projectSlug string, subforums []string, threadId int, postId int) string { func BuildForumNewThread(projectSlug string, subforums []string) string {
var builder strings.Builder builder := buildForumCategoryPath(subforums)
builder.WriteString("/forums") builder.WriteString("/new")
for _, subforum := range subforums {
subforum = strings.TrimSpace(subforum)
if strings.Contains(subforum, "/") {
panic(oops.New(nil, "Tried building forum thread url with / in subforum name"))
}
if len(subforum) == 0 {
panic(oops.New(nil, "Tried building forum thread url with blank subforum"))
}
builder.WriteRune('/')
builder.WriteString(subforum)
}
builder.WriteString("/t/")
builder.WriteString(strconv.Itoa(threadId))
builder.WriteString("/p/")
builder.WriteString(strconv.Itoa(postId))
return ProjectUrl(builder.String(), nil, projectSlug) return ProjectUrl(builder.String(), nil, projectSlug)
} }
var RegexForumPostDelete = regexp.MustCompile(``) // TODO var RegexForumThread = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)(-([^/]+))?(/(?P<page>\d+))?$`)
func BuildForumThread(projectSlug string, subforums []string, threadId int, title string, page int) string {
builder := buildForumThreadPath(subforums, threadId, title, page)
return ProjectUrl(builder.String(), nil, projectSlug)
}
func BuildForumThreadWithPostHash(projectSlug string, subforums []string, threadId int, title string, page int, postId int) string {
builder := buildForumThreadPath(subforums, threadId, title, page)
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+)$`)
func BuildForumPost(projectSlug string, subforums []string, threadId int, postId int) string {
builder := buildForumPostPath(subforums, threadId, 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$`)
func BuildForumPostDelete(projectSlug string, subforums []string, threadId int, postId int) string { func BuildForumPostDelete(projectSlug string, subforums []string, threadId int, postId int) string {
return BuildForumPost(projectSlug, subforums, threadId, postId) + "/delete" builder := buildForumPostPath(subforums, threadId, postId)
builder.WriteString("/delete")
return ProjectUrl(builder.String(), nil, projectSlug)
} }
var RegexForumPostEdit = regexp.MustCompile(``) // TODO var RegexForumPostEdit = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/edit$`)
func BuildForumPostEdit(projectSlug string, subforums []string, threadId int, postId int) string { func BuildForumPostEdit(projectSlug string, subforums []string, threadId int, postId int) string {
return BuildForumPost(projectSlug, subforums, threadId, postId) + "/edit" builder := buildForumPostPath(subforums, threadId, postId)
builder.WriteString("/edit")
return ProjectUrl(builder.String(), nil, projectSlug)
} }
var RegexForumPostReply = regexp.MustCompile(``) // TODO(asaf): Ha ha! I, Ben, have played a trick on you, and forced you to do this regex as well! var RegexForumPostReply = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/reply$`)
// TODO: It's kinda weird that we have "replies" to a specific post. That's not how the data works. I guess this just affects what you see as the "post you're replying to" on the post composer page? // TODO: It's kinda weird that we have "replies" to a specific post. That's not how the data works. I guess this just affects what you see as the "post you're replying to" on the post composer page?
func BuildForumPostReply(projectSlug string, subforums []string, threadId int, postId int) string { func BuildForumPostReply(projectSlug string, subforums []string, threadId int, postId int) string {
return BuildForumPost(projectSlug, subforums, threadId, postId) + "/reply" builder := buildForumPostPath(subforums, threadId, postId)
builder.WriteString("/reply")
return ProjectUrl(builder.String(), nil, projectSlug)
} }
var RegexForumPostQuote = regexp.MustCompile(``) // TODO var RegexForumPostQuote = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/quote$`)
func BuildForumPostQuote(projectSlug string, subforums []string, threadId int, postId int) string { func BuildForumPostQuote(projectSlug string, subforums []string, threadId int, postId int) string {
return BuildForumPost(projectSlug, subforums, threadId, postId) + "/quote" builder := buildForumPostPath(subforums, threadId, postId)
builder.WriteString("/quote")
return ProjectUrl(builder.String(), nil, projectSlug)
} }
/*
* Blog
*/
var RegexBlogsRedirect = regexp.MustCompile(`^/blogs`)
var RegexBlog = regexp.MustCompile(`^/blog(/(?P<page>\d+))?$`)
func BuildBlog(projectSlug string, page int) string {
if page < 1 {
panic(oops.New(nil, "Invalid blog page (%d), must be >= 1", page))
}
path := "/blog"
if page > 1 {
path += "/" + strconv.Itoa(page)
}
return ProjectUrl(path, nil, projectSlug)
}
var RegexBlogThread = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)(-([^/]+))?(/(?P<page>\d+))?$`)
func BuildBlogThread(projectSlug string, threadId int, title string, page int) string {
builder := buildBlogThreadPath(threadId, title, page)
return ProjectUrl(builder.String(), nil, projectSlug)
}
func BuildBlogThreadWithPostHash(projectSlug string, threadId int, title string, page int, postId int) string {
builder := buildBlogThreadPath(threadId, title, page)
return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId))
}
var RegexBlogPost = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)$`)
func BuildBlogPost(projectSlug string, threadId int, postId int) string {
builder := buildBlogPostPath(threadId, postId)
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexBlogPostDelete = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)/delete$`)
func BuildBlogPostDelete(projectSlug string, threadId int, postId int) string {
builder := buildBlogPostPath(threadId, postId)
builder.WriteString("/delete")
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexBlogPostEdit = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)/edit$`)
func BuildBlogPostEdit(projectSlug string, threadId int, postId int) string {
builder := buildBlogPostPath(threadId, postId)
builder.WriteString("/edit")
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexBlogPostReply = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)/reply$`)
func BuildBlogPostReply(projectSlug string, threadId int, postId int) string {
builder := buildBlogPostPath(threadId, postId)
builder.WriteString("/reply")
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexBlogPostQuote = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)/quote$`)
func BuildBlogPostQuote(projectSlug string, threadId int, postId int) string {
builder := buildBlogPostPath(threadId, postId)
builder.WriteString("/quote")
return ProjectUrl(builder.String(), nil, projectSlug)
}
/*
* Wiki
*/
var RegexWiki = regexp.MustCompile(`^/wiki$`)
func BuildWiki(projectSlug string) string {
return ProjectUrl("/wiki", nil, projectSlug)
}
var RegexWikiIndex = regexp.MustCompile(`^/wiki/index$`)
func BuildWikiIndex(projectSlug string) string {
return ProjectUrl("/wiki/index", nil, projectSlug)
}
var RegexWikiArticle = regexp.MustCompile(`^/wiki/(?P<articleid>\d+)(-([^/])+)?$`)
func BuildWikiArticle(projectSlug string, articleId int, title string) string {
builder := buildWikiArticlePath(articleId, title)
return ProjectUrl(builder.String(), nil, projectSlug)
}
func BuildWikiArticleWithSectionName(projectSlug string, articleId int, title string, sectionName string) string {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
builder := buildWikiTalkPath(articleId, postId)
builder.WriteString("/quote")
return ProjectUrl(builder.String(), nil, projectSlug)
}
/*
* Library
*/
var RegexLibrary = regexp.MustCompile(`^/library$`)
func BuildLibrary(projectSlug string) string {
return ProjectUrl("/library", nil, projectSlug)
}
var RegexLibraryAll = regexp.MustCompile(`^/library/all$`)
func BuildLibraryAll(projectSlug string) string {
return ProjectUrl("/library/all", nil, projectSlug)
}
var RegexLibraryTopic = regexp.MustCompile(`^/library/topic/(?P<topicid>\d+)$`)
func BuildLibraryTopic(projectSlug string, topicId int) string {
if topicId < 1 {
panic(oops.New(nil, "Invalid library topic ID (%d), must be >= 1", topicId))
}
var builder strings.Builder
builder.WriteString("/library/topic/")
builder.WriteString(strconv.Itoa(topicId))
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexLibraryResource = regexp.MustCompile(`^/library/resource/(?P<resourceid>\d+)$`)
func BuildLibraryResource(projectSlug string, resourceId int) string {
builder := buildLibraryResourcePath(resourceId)
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 {
builder := buildLibraryDiscussionPath(resourceId, threadId, page)
return ProjectUrl(builder.String(), nil, projectSlug)
}
func BuildLibraryDiscussionWithPostHash(projectSlug string, resourceId int, threadId int, page int, postId int) string {
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 {
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 {
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 {
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 {
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 {
builder := buildLibraryPostPath(resourceId, threadId, postId)
builder.WriteString("/quote")
return ProjectUrl(builder.String(), nil, projectSlug)
}
/*
* Assets
*/
var RegexProjectCSS = regexp.MustCompile("^/assets/project.css$") var RegexProjectCSS = regexp.MustCompile("^/assets/project.css$")
func BuildProjectCSS(color string) string { func BuildProjectCSS(color string) string {
@ -218,4 +589,219 @@ func BuildPublic(filepath string) string {
return Url(builder.String(), nil) return Url(builder.String(), nil)
} }
/*
* Other
*/
var RegexMarkRead = regexp.MustCompile(`^/_markread/(?P<catid>\d+)$`)
// NOTE(asaf): categoryId == 0 means ALL CATEGORIES
func BuildMarkRead(categoryId int) string {
if categoryId < 0 {
panic(oops.New(nil, "Invalid category ID (%d), must be >= 0", categoryId))
}
var builder strings.Builder
builder.WriteString("/_markread/")
builder.WriteString(strconv.Itoa(categoryId))
return Url(builder.String(), nil)
}
var RegexCatchAll = regexp.MustCompile("") var RegexCatchAll = regexp.MustCompile("")
/*
* Helper functions
*/
func buildForumCategoryPath(subforums []string) *strings.Builder {
for _, subforum := range subforums {
if strings.Contains(subforum, "/") {
panic(oops.New(nil, "Tried building forum url with / in subforum name"))
}
subforum = strings.TrimSpace(subforum)
if len(subforum) == 0 {
panic(oops.New(nil, "Tried building forum url with blank subforum"))
}
}
var builder strings.Builder
builder.WriteString("/forums")
for _, subforum := range subforums {
builder.WriteRune('/')
builder.WriteString(subforum)
}
return &builder
}
func buildForumThreadPath(subforums []string, threadId int, title string, page int) *strings.Builder {
if page < 1 {
panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page))
}
if threadId < 1 {
panic(oops.New(nil, "Invalid forum thread ID (%d), must be >= 1", threadId))
}
builder := buildForumCategoryPath(subforums)
builder.WriteString("/t/")
builder.WriteString(strconv.Itoa(threadId))
if len(title) > 0 {
builder.WriteRune('-')
builder.WriteString(PathSafeTitle(title))
}
if page > 1 {
builder.WriteRune('/')
builder.WriteString(strconv.Itoa(page))
}
return builder
}
func buildForumPostPath(subforums []string, threadId int, postId int) *strings.Builder {
if threadId < 1 {
panic(oops.New(nil, "Invalid forum thread ID (%d), must be >= 1", threadId))
}
if postId < 1 {
panic(oops.New(nil, "Invalid forum post ID (%d), must be >= 1", postId))
}
builder := buildForumCategoryPath(subforums)
builder.WriteString("/t/")
builder.WriteString(strconv.Itoa(threadId))
builder.WriteString("/p/")
builder.WriteString(strconv.Itoa(postId))
return builder
}
func buildBlogThreadPath(threadId int, title string, page int) *strings.Builder {
if page < 1 {
panic(oops.New(nil, "Invalid blog thread page (%d), must be >= 1", page))
}
if threadId < 1 {
panic(oops.New(nil, "Invalid blog thread ID (%d), must be >= 1", threadId))
}
var builder strings.Builder
builder.WriteString("/blog/p/")
builder.WriteString(strconv.Itoa(threadId))
if len(title) > 0 {
builder.WriteRune('-')
builder.WriteString(PathSafeTitle(title))
}
if page > 1 {
builder.WriteRune('/')
builder.WriteString(strconv.Itoa(page))
}
return &builder
}
func buildBlogPostPath(threadId int, postId int) *strings.Builder {
if threadId < 1 {
panic(oops.New(nil, "Invalid blog thread ID (%d), must be >= 1", threadId))
}
if postId < 1 {
panic(oops.New(nil, "Invalid blog post ID (%d), must be >= 1", postId))
}
var builder strings.Builder
builder.WriteString("/blog/p/")
builder.WriteString(strconv.Itoa(threadId))
builder.WriteString("/e/")
builder.WriteString(strconv.Itoa(postId))
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))
}
var builder strings.Builder
builder.WriteString("/library/resource/")
builder.WriteString(strconv.Itoa(resourceId))
return &builder
}
func buildLibraryDiscussionPath(resourceId int, threadId int, page int) *strings.Builder {
if page < 1 {
panic(oops.New(nil, "Invalid page number (%d), must be >= 1", page))
}
if threadId < 1 {
panic(oops.New(nil, "Invalid library thread ID (%d), must be >= 1", threadId))
}
builder := buildLibraryResourcePath(resourceId)
builder.WriteString("/d/")
builder.WriteString(strconv.Itoa(threadId))
if page > 1 {
builder.WriteRune('/')
builder.WriteString(strconv.Itoa(page))
}
return builder
}
func buildLibraryPostPath(resourceId int, threadId int, postId int) *strings.Builder {
if threadId < 1 {
panic(oops.New(nil, "Invalid library thread ID (%d), must be >= 1", threadId))
}
if postId < 1 {
panic(oops.New(nil, "Invalid library post ID (%d), must be >= 1", postId))
}
builder := buildLibraryResourcePath(resourceId)
builder.WriteString("/d/")
builder.WriteString(strconv.Itoa(threadId))
builder.WriteString("/p/")
builder.WriteString(strconv.Itoa(postId))
return builder
}
var PathCharsToClear = regexp.MustCompile("[$&`<>{}()\\[\\]\"+#%@;=?\\\\^|~]")
var PathCharsToReplace = regexp.MustCompile("[ :/\\\\]")
func PathSafeTitle(title string) string {
title = strings.ToLower(title)
title = PathCharsToReplace.ReplaceAllLiteralString(title, "_")
title = PathCharsToClear.ReplaceAllLiteralString(title, "")
title = url.PathEscape(title)
return title
}

View File

@ -38,7 +38,8 @@ type CategoryTree map[int]*CategoryTreeNode
type CategoryTreeNode struct { type CategoryTreeNode struct {
Category Category
Parent *CategoryTreeNode Parent *CategoryTreeNode
Children []*CategoryTreeNode
} }
func (node *CategoryTreeNode) GetLineage() []*Category { func (node *CategoryTreeNode) GetLineage() []*Category {
@ -84,6 +85,15 @@ func GetFullCategoryTree(ctx context.Context, conn *pgxpool.Pool) CategoryTree {
node.Parent = catTreeMap[*node.ParentID] 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 return catTreeMap
} }
@ -109,6 +119,10 @@ func (cl *CategoryLineageBuilder) GetLineage(catId int) []*Category {
return cl.CategoryCache[catId] return cl.CategoryCache[catId]
} }
func (cl *CategoryLineageBuilder) GetSubforumLineage(catId int) []*Category {
return cl.GetLineage(catId)[1:]
}
func (cl *CategoryLineageBuilder) GetLineageSlugs(catId int) []string { func (cl *CategoryLineageBuilder) GetLineageSlugs(catId int) []string {
_, ok := cl.SlugCache[catId] _, ok := cl.SlugCache[catId]
if !ok { if !ok {
@ -125,3 +139,16 @@ func (cl *CategoryLineageBuilder) GetLineageSlugs(catId int) []string {
} }
return cl.SlugCache[catId] return cl.SlugCache[catId]
} }
func (cl *CategoryLineageBuilder) GetSubforumLineageSlugs(catId int) []string {
return cl.GetLineageSlugs(catId)[1:]
}
func (cl *CategoryLineageBuilder) FindIdBySlug(projectId int, slug string) int {
for _, node := range cl.Tree {
if node.Slug != nil && *node.Slug == slug && node.ProjectID != nil && *node.ProjectID == projectId {
return node.ID
}
}
return -1
}

View File

@ -0,0 +1,16 @@
package models
type LibraryResource struct {
ID int `db:"id"`
CategoryID int `db:"category_id"`
ProjectID *int `db:"project_id"`
Name string `db:"name"`
Description string `db:"description"`
Url string `db:"url"`
ContentType string `db:"content_type"`
Size int `db:"size"`
IsDeleted bool `db:"is_deleted"`
PreventsEmbed bool `db:"prevents_embed"`
}

View File

@ -27,12 +27,14 @@ const (
type Project struct { type Project struct {
ID int `db:"id"` ID int `db:"id"`
ForumID *int `db:"forum_id"`
Slug string `db:"slug"` Slug string `db:"slug"`
Name string `db:"name"` Name string `db:"name"`
Blurb string `db:"blurb"` Blurb string `db:"blurb"`
Description string `db:"description"` Description string `db:"description"`
Lifecycle ProjectLifecycle `db:"lifecycle"` Lifecycle ProjectLifecycle `db:"lifecycle"` // TODO(asaf): Ensure we only fetch projects in the correct lifecycle phase everywhere.
Color1 string `db:"color_1"` Color1 string `db:"color_1"`
Color2 string `db:"color_2"` Color2 string `db:"color_2"`

View File

@ -57,6 +57,7 @@ func ProjectToTemplate(p *models.Project) Project {
Subdomain: p.Subdomain(), Subdomain: p.Subdomain(),
Color1: p.Color1, Color1: p.Color1,
Color2: p.Color2, Color2: p.Color2,
Url: hmnurl.BuildProjectHomepage(p.Slug),
IsHMN: p.IsHMN(), IsHMN: p.IsHMN(),

View File

@ -10,9 +10,9 @@
<div class="content-block"> <div class="content-block">
<div class="optionbar"> <div class="optionbar">
<div class="options"> <div class="options">
<a class="button" href="{{ url "/atom" }}"><span class="icon big">4</span> RSS Feed</span></a> <a class="button" href="{{ .AtomFeedUrl }}"><span class="icon big">4</span> RSS Feed</span></a>
{{ if .User }} {{ if .User }}
<a class="button" href="{{ url "/markread" }}"><span class="big">&#x2713;</span> Mark all posts on site as read</a> <a class="button" href="{{ .MarkAllReadUrl }}"><span class="big">&#x2713;</span> Mark all posts on site as read</a>
{{ end }} {{ end }}
</div> </div>
<div class="options"> <div class="options">

View File

@ -35,11 +35,11 @@
{{ define "forum_category_options" }} {{ define "forum_category_options" }}
<div class="options"> <div class="options">
{{ if .User }} {{ if .User }}
<a class="button new-thread" href="{{ printf "%s/t/new" .CategoryUrl }}"><span class="big">+</span> New Thread</a> <a class="button new-thread" href="{{ .NewThreadUrl }}"><span class="big">+</span> New Thread</a>
{{/* TODO(asaf): Mark read should probably be a POST, since it's destructive and we would probably want CSRF for it */}} {{/* TODO(asaf): Mark read should probably be a POST, since it's destructive and we would probably want CSRF for it */}}
<a class="button" href="{{ printf "%s/markread" .CategoryUrl }}"><span class="big">&#x2713;</span> Mark threads here as read</a> <a class="button" href="{{ .MarkReadUrl }}"><span class="big">&#x2713;</span> Mark threads here as read</a>
{{ else }} {{ else }}
<a class="button" href="{% url 'member_login' subdomain=request.subdomain %}">Log in to post a new thread</a> <a class="button" href="{{ .LoginPageUrl }}">Log in to post a new thread</a>
{{ end }} {{ end }}
</div> </div>
<div class="options"> <div class="options">

View File

@ -120,7 +120,7 @@
{{ else if .User }} {{ else if .User }}
<a class="button" href="{{ .ReplyUrl }}">&#10551; Reply to Thread</a> <a class="button" href="{{ .ReplyUrl }}">&#10551; Reply to Thread</a>
{{ else }} {{ else }}
<span><a href="{% url 'member_login' subdomain=None %}">Log in</a> to reply</span> <span><a href="{{ .LoginPageUrl }}">Log in</a> to reply</span>
{{ end }} {{ end }}
</div> </div>
<div class="options order-0 order-last-ns"> <div class="options order-0 order-last-ns">

View File

@ -1,35 +1,35 @@
<footer class="pa3 pa4-l"> <footer class="pa3 pa4-l">
<h2> <h2>
Community by <a href="{{ url "/" }}">handmade.network</a> Community by <a href="{{ .Footer.HomepageUrl }}">handmade.network</a>
</h2> </h2>
<ul class="list"> <ul class="list">
{{ $footerClasses := "ma0 pa0 dib-ns" }} {{ $footerClasses := "ma0 pa0 dib-ns" }}
<li class="{{ $footerClasses }}"> <li class="{{ $footerClasses }}">
<a href="{{ url "/" }}">Main Page</a> <a href="{{ .Footer.HomepageUrl }}">Main Page</a>
</li> </li>
<li class="{{ $footerClasses }}"> <li class="{{ $footerClasses }}">
<a href="{{ url "/about" }}">About</a> <a href="{{ .Footer.AboutUrl }}">About</a>
</li> </li>
<li class="{{ $footerClasses }}"> <li class="{{ $footerClasses }}">
<a href="{{ url "/manifesto" }}">Handmade Manifesto</a> <a href="{{ .Footer.ManifestoUrl }}">Handmade Manifesto</a>
</li> </li>
<li class="{{ $footerClasses }}"> <li class="{{ $footerClasses }}">
<a href="{{ url "/code-of-conduct" }}">Code of Conduct</a> <a href="{{ .Footer.CodeOfConductUrl }}">Code of Conduct</a>
</li> </li>
<li class="{{ $footerClasses }}"> <li class="{{ $footerClasses }}">
<a href="{{ url "/communication-guidelines" }}">Communication Guidelines</a> <a href="{{ .Footer.CommunicationGuidelinesUrl }}">Communication Guidelines</a>
</li> </li>
<li class="{{ $footerClasses }}"> <li class="{{ $footerClasses }}">
<a href="{{ url "/projects" }}">Project Index</a> <a href="{{ .Footer.ProjectIndexUrl }}">Project Index</a>
</li> </li>
<li class="{{ $footerClasses }}"> <li class="{{ $footerClasses }}">
<a href="{{ url "/forums" }}">Community Forums</a> <a href="{{ .Footer.ForumsUrl }}">Community Forums</a>
</li> </li>
<li class="{{ $footerClasses }}"> <li class="{{ $footerClasses }}">
<a href="{{ url "/contact" }}">Contact</a> <a href="{{ .Footer.ContactUrl }}">Contact</a>
</li> </li>
<li class="{{ $footerClasses }}"> <li class="{{ $footerClasses }}">
<a href="{{ url "/sitemap" }}">Sitemap</a> <a href="{{ .Footer.SitemapUrl }}">Sitemap</a>
</li> </li>
</ul> </ul>
</footer> </footer>

View File

@ -3,15 +3,15 @@
<div class="user-options flex justify-center justify-end-ns"> <div class="user-options flex justify-center justify-end-ns">
{{ if .User }} {{ if .User }}
{{ if .User.IsSuperuser }} {{ if .User.IsSuperuser }}
<a class="admin-panel" href="{{ url "/admin_panel" }}"><span class="icon-settings"> Admin</span></a> <a class="admin-panel" href="{{ .Header.AdminUrl }}"><span class="icon-settings"> Admin</span></a>
{{ end }} {{ end }}
<a class="username settings" href="{{ url "/member_settings" }}"><span class="icon-settings"></span> {{ .User.Username }}</a> <a class="username settings" href="{{ .Header.MemberSettingsUrl }}"><span class="icon-settings"></span> {{ .User.Username }}</a>
<a class="logout" href="{{ url "/logout" }}"><span class="icon-logout"></span> Logout</a> <a class="logout" href="{{ .Header.LogoutActionUrl }}"><span class="icon-logout"></span> Logout</a>
{{ else }} {{ else }}
<a class="register" id="register-link" href="{{ url "/member_register" }}">Register</a> <a class="register" id="register-link" href="{{ .Header.RegisterUrl }}">Register</a>
<a class="login" id="login-link" href="{{ projecturl "/login" .Project }}">Log in</a> <a class="login" id="login-link" href="{{ .LoginPageUrl }}">Log in</a>
<div id="login-popup"> <div id="login-popup">
<form action="{{ projecturl "/login" .Project }}" method="post"> <form action="{{ .Header.LoginActionUrl }}" method="post">
{{/* TODO: CSRF */}} {{/* TODO: CSRF */}}
<table> <table>
<tr> <tr>
@ -33,41 +33,41 @@
</div> </div>
<div class="menu-bar flex flex-column flex-row-l justify-between {% if project and project.slug != 'hmn' %}project{% endif %}"> <div class="menu-bar flex flex-column flex-row-l justify-between {% if project and project.slug != 'hmn' %}project{% endif %}">
<div class="flex flex-column flex-row-ns"> <div class="flex flex-column flex-row-ns">
<a href="{{ url "/" }}" class="logo hmdev-logo"> <a href="{{ .Header.HMNHomepageUrl }}" class="logo hmdev-logo">
<div class="underscore"></div> <div class="underscore"></div>
</a> </a>
<div class="items flex items-center justify-center justify-start-ns"> <div class="items flex items-center justify-center justify-start-ns">
{{ if not .Project.IsHMN }} {{ if not .Project.IsHMN }}
<a class="project-logo" href="{{ projecturl "/" .Project }}"> <a class="project-logo" href="{{ .Header.ProjectHomepageUrl }}">
<h1>{{ .Project.Name }}</h1> <h1>{{ .Project.Name }}</h1>
</a> </a>
{{ end }} {{ end }}
{{ if .Project.HasBlog }} {{ if .Project.HasBlog }}
<a href="{{ projecturl "/blog" .Project }}" class="blog">Blog</a> <a href="{{ .Header.BlogUrl }}" class="blog">Blog</a>
{{ end }} {{ end }}
{{ if .Project.HasForum }} {{ if .Project.HasForum }}
<a href="{{ projecturl "/forums" .Project }}" class="forums">Forums</a> <a href="{{ .Header.ForumsUrl }}" class="forums">Forums</a>
{{ end }} {{ end }}
{{ if .Project.HasWiki }} {{ if .Project.HasWiki }}
<a href="{{ projecturl "/wiki" .Project }}" class="wiki">Wiki</a> <a href="{{ .Header.WikiUrl }}" class="wiki">Wiki</a>
{{ end }} {{ end }}
{{ if .Project.HasLibrary }} {{ if .Project.HasLibrary }}
<a href="{{ projecturl "/library" .Project }}" class="library">Library</a> <a href="{{ .Header.LibraryUrl }}" class="library">Library</a>
{{ end }} {{ end }}
{{ if .Project.IsHMN }} {{ if .Project.IsHMN }}
<a href="{{ projecturl "/manifesto" .Project }}" class="misson">Mission</a> <a href="{{ .Header.ManifestoUrl }}" class="misson">Mission</a>
{{ end }} {{ end }}
{{/* {% if project.default_annotation_category %} */}} {{/* {% if project.default_annotation_category %} */}}
{{ if false }} {{ if false }}
<a href="{% url 'episode_list_topic' project.slug project.default_annotation_category %}" class="annotations">Episode Guide</a> <a href="{{ .Header.EpisodeGuideUrl }}" class="annotations">Episode Guide</a>
{{ end }} {{ end }}
{{/* {% if showEditLink == True %} */}} {{/* {% if showEditLink == True %} */}}
{{/* {{ if false }} {{/* {{ if false }}
<a class="edit" href="{{ EditLink }}" title="Edit {{ project.name }}"><span class="icon">0</span>&nbsp;Settings</a> <a class="edit" href="{{ .Header.EditUrl }}" title="Edit {{ project.name }}"><span class="icon">0</span>&nbsp;Settings</a>
{{ end }} */}} {{ end }} */}}
</div> </div>
</div> </div>
<form class="dn ma0 flex-l flex-column justify-center items-end" method="post" action="{{ url "/search" }}" target="_blank"> <form class="dn ma0 flex-l flex-column justify-center items-end" method="post" action="{{ .Header.SearchActionUrl }}" target="_blank">
{{/* TODO: CSRF? */}} {{/* TODO: CSRF? */}}
<input class="site-search bn lite pa2 fira" type="text" name="term" value="" placeholder="Search with Google" size="17" /> <input class="site-search bn lite pa2 fira" type="text" name="term" value="" placeholder="Search with Google" size="17" />
<input id="search_button_homepage" type="submit" value="Go"/> <input id="search_button_homepage" type="submit" value="Go"/>

View File

@ -35,25 +35,25 @@
<h2 class="di-l mr2-l">Around the Network</h2> <h2 class="di-l mr2-l">Around the Network</h2>
<ul class="list dib-l"> <ul class="list dib-l">
<li class="dib-ns ma0 ph2"> <li class="dib-ns ma0 ph2">
<a href="{{ url "/feed" }}">View all posts on HMN</a> <a href="{{ .FeedUrl }}">View all posts on HMN</a>
</li> </li>
<li class="dib-ns ma0 ph2"> <li class="dib-ns ma0 ph2">
<a href="{{ url "/podcast" }}">Podcast</a> <a href="{{ .PodcastUrl }}">Podcast</a>
</li> </li>
{{/* TODO: Make a better IRC intro page because the current one is trash anyway */}} {{/* TODO: Make a better IRC intro page because the current one is trash anyway */}}
{{/* {{/*
<li class="dib-ns ma0 ph2"> <li class="dib-ns ma0 ph2">
<a href="{{ url "/streams" }}">See who's live</a> <a href="{{ .StreamsUrl }}">See who's live</a>
</li> </li>
<li class="dib-ns ma0 ph2"> <li class="dib-ns ma0 ph2">
<a href="/blogs/p/1138-%5Btutorial%5D_handmade_network_irc" target="_blank">Chat in IRC</a> <a href="{{ .IRCUrl }}" target="_blank">Chat in IRC</a>
</li> </li>
*/}} */}}
<li class="dib-ns ma0 ph2"> <li class="dib-ns ma0 ph2">
<a href="https://discord.gg/hxWxDee" target="_blank">Chat on Discord</a> <a href="{{ .DiscordUrl }}" target="_blank">Chat on Discord</a>
</li> </li>
<li class="dib-ns ma0 ph2"> <li class="dib-ns ma0 ph2">
<a href="https://handmadedev.show/" target="_blank">See the Show</a> <a href="{{ .ShowUrl }}" target="_blank">See the Show</a>
</li> </li>
</ul> </ul>
</div> </div>
@ -78,7 +78,7 @@
{{ $c1 := hex2color $proj.Color1 }} {{ $c1 := hex2color $proj.Color1 }}
<a <a
class="project-title" class="project-title"
href="{{ projecturl "/" $proj }}" href="{{ $proj.Url }}"
> >
<h2 class="ph3">{{ $proj.Name }}</h2> <h2 class="ph3">{{ $proj.Name }}</h2>
</a> </a>
@ -92,7 +92,7 @@
{{ end }} {{ end }}
<div class="ph3 thread unread more"> <div class="ph3 thread unread more">
<a class="title" href="{{ projecturl "/forums" $proj }}"> <a class="title" href="{{ $entry.ForumsUrl }}">
More posts &rarr; More posts &rarr;
</a> </a>
</div> </div>
@ -113,7 +113,7 @@
<h2 class="di-l mr2-l">Community Showcase</h2> <h2 class="di-l mr2-l">Community Showcase</h2>
<ul class="list dib-l"> <ul class="list dib-l">
<li class="dib-ns ma0 ph2"> <li class="dib-ns ma0 ph2">
<a href="{% url 'showcase' %}">View all</a> <a href="{{ .ShowcaseUrl }}">View all</a>
</li> </li>
</ul> </ul>
</div> </div>
@ -129,7 +129,7 @@
</div> </div>
<div class="c--dimmer i pv2 ph3 ph0-ns"> <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="https://discord.gg/hxWxDee" target="_blank">Join us on Discord.</a> 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>
</div> </div>
<script> <script>

View File

@ -40,7 +40,7 @@
</style> </style>
{{ block "extrahead" . }}{{ end }} {{ block "extrahead" . }}{{ end }}
<link rel="stylesheet" href="{{ statictheme .Theme "theme.css" }}" /> <link rel="stylesheet" href="{{ statictheme .Theme "theme.css" }}" />
<link rel="stylesheet" href="{{ urlq "assets/project.css" (query "color" .Project.Color1) }}" /> <link rel="stylesheet" href="{{ .ProjectCSSUrl }}" />
<link rel="apple-touch-icon" sizes="57x57" href="{{ static "apple-icon-57x57.png" }}"> <link rel="apple-touch-icon" sizes="57x57" href="{{ static "apple-icon-57x57.png" }}">
<link rel="apple-touch-icon" sizes="60x60" href="{{ static "apple-icon-60x60.png" }}"> <link rel="apple-touch-icon" sizes="60x60" href="{{ static "apple-icon-60x60.png" }}">
<link rel="apple-touch-icon" sizes="72x72" href="{{ static "apple-icon-72x72.png" }}"> <link rel="apple-touch-icon" sizes="72x72" href="{{ static "apple-icon-72x72.png" }}">

View File

@ -14,8 +14,44 @@ type BaseData struct {
BodyClasses []string BodyClasses []string
Breadcrumbs []Breadcrumb Breadcrumbs []Breadcrumb
LoginPageUrl string
ProjectCSSUrl string
Project Project Project Project
User *User User *User
Header Header
Footer Footer
}
type Header struct {
AdminUrl string
MemberSettingsUrl string
LoginActionUrl string
LogoutActionUrl string
RegisterUrl string
HMNHomepageUrl string
ProjectHomepageUrl string
BlogUrl string
ForumsUrl string
WikiUrl string
LibraryUrl string
ManifestoUrl string
EpisodeGuideUrl string
EditUrl string
SearchActionUrl string
}
type Footer struct {
HomepageUrl string
AboutUrl string
ManifestoUrl string
CodeOfConductUrl string
CommunicationGuidelinesUrl string
ProjectIndexUrl string
ForumsUrl string
ContactUrl string
SitemapUrl string
} }
type Thread struct { type Thread struct {
@ -54,6 +90,7 @@ type Project struct {
Subdomain string Subdomain string
Color1 string Color1 string
Color2 string Color2 string
Url string
IsHMN bool IsHMN bool

View File

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

View File

@ -17,8 +17,10 @@ import (
type FeedData struct { type FeedData struct {
templates.BaseData templates.BaseData
Posts []templates.PostListItem Posts []templates.PostListItem
Pagination templates.Pagination Pagination templates.Pagination
AtomFeedUrl string
MarkAllReadUrl string
} }
func Feed(c *RequestContext) ResponseData { func Feed(c *RequestContext) ResponseData {
@ -76,13 +78,14 @@ func Feed(c *RequestContext) ResponseData {
c.Perf.StartBlock("SQL", "Fetch posts") c.Perf.StartBlock("SQL", "Fetch posts")
type feedPostQuery struct { type feedPostQuery struct {
Post models.Post `db:"post"` Post models.Post `db:"post"`
Thread models.Thread `db:"thread"` Thread models.Thread `db:"thread"`
Cat models.Category `db:"cat"` Cat models.Category `db:"cat"`
Proj models.Project `db:"proj"` Proj models.Project `db:"proj"`
User models.User `db:"auth_user"` LibraryResource *models.LibraryResource `db:"lib_resource"`
ThreadLastReadTime *time.Time `db:"tlri.lastread"` User models.User `db:"auth_user"`
CatLastReadTime *time.Time `db:"clri.lastread"` ThreadLastReadTime *time.Time `db:"tlri.lastread"`
CatLastReadTime *time.Time `db:"clri.lastread"`
} }
posts, err := db.Query(c.Context(), c.Conn, feedPostQuery{}, posts, err := db.Query(c.Context(), c.Conn, feedPostQuery{},
` `
@ -92,15 +95,16 @@ func Feed(c *RequestContext) ResponseData {
JOIN handmade_thread AS thread ON thread.id = post.thread_id JOIN handmade_thread AS thread ON thread.id = post.thread_id
JOIN handmade_category AS cat ON cat.id = post.category_id JOIN handmade_category AS cat ON cat.id = post.category_id
JOIN handmade_project AS proj ON proj.id = post.project_id JOIN handmade_project AS proj ON proj.id = post.project_id
LEFT OUTER JOIN handmade_threadlastreadinfo AS tlri ON ( LEFT JOIN handmade_threadlastreadinfo AS tlri ON (
tlri.thread_id = post.thread_id tlri.thread_id = post.thread_id
AND tlri.user_id = $1 AND tlri.user_id = $1
) )
LEFT OUTER JOIN handmade_categorylastreadinfo AS clri ON ( LEFT JOIN handmade_categorylastreadinfo AS clri ON (
clri.category_id = post.category_id clri.category_id = post.category_id
AND clri.user_id = $1 AND clri.user_id = $1
) )
LEFT OUTER JOIN auth_user ON post.author_id = auth_user.id LEFT JOIN auth_user ON post.author_id = auth_user.id
LEFT JOIN handmade_libraryresource as lib_resource ON lib_resource.category_id = post.category_id
WHERE WHERE
post.category_kind = ANY ($2) post.category_kind = ANY ($2)
AND post.deleted = FALSE AND post.deleted = FALSE
@ -123,22 +127,6 @@ func Feed(c *RequestContext) ResponseData {
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock() c.Perf.EndBlock()
categoryUrlCache := make(map[int]string)
getCategoryUrl := func(projectSlug string, cat *models.Category) string {
_, ok := categoryUrlCache[cat.ID]
if !ok {
lineageNames := lineageBuilder.GetLineageSlugs(cat.ID)
switch cat.Kind {
case models.CatKindForum:
categoryUrlCache[cat.ID] = hmnurl.BuildForumCategory(projectSlug, lineageNames[1:], 1)
// TODO(asaf): Add more kinds!!!
default:
categoryUrlCache[cat.ID] = ""
}
}
return categoryUrlCache[cat.ID]
}
c.Perf.StartBlock("FEED", "Build post items") c.Perf.StartBlock("FEED", "Build post items")
var postItems []templates.PostListItem var postItems []templates.PostListItem
for _, iPostResult := range posts.ToSlice() { for _, iPostResult := range posts.ToSlice() {
@ -151,42 +139,16 @@ func Feed(c *RequestContext) ResponseData {
hasRead = true hasRead = true
} }
breadcrumbs := make([]templates.Breadcrumb, 0, len(lineageBuilder.GetLineage(postResult.Cat.ID))) postItems = append(postItems, MakePostListItem(
breadcrumbs = append(breadcrumbs, templates.Breadcrumb{ lineageBuilder,
Name: postResult.Proj.Name, &postResult.Proj,
Url: hmnurl.ProjectUrl("/", nil, postResult.Proj.Slug), &postResult.Thread,
}) &postResult.Post,
if postResult.Post.CategoryKind == models.CatKindLibraryResource { &postResult.User,
// TODO(asaf): Fetch library root topic for the project and construct breadcrumb for it postResult.LibraryResource,
} else { !hasRead,
lineage := lineageBuilder.GetLineage(postResult.Cat.ID) true,
for i, cat := range lineage { ))
name := *cat.Name
if i == 0 {
switch cat.Kind {
case models.CatKindForum:
name = "Forums"
case models.CatKindBlog:
name = "Blog"
}
}
breadcrumbs = append(breadcrumbs, templates.Breadcrumb{
Name: name,
Url: getCategoryUrl(postResult.Proj.Subdomain(), cat),
})
}
}
postItems = append(postItems, templates.PostListItem{
Title: postResult.Thread.Title,
Url: hmnurl.BuildForumPost(postResult.Proj.Subdomain(), lineageBuilder.GetLineageSlugs(postResult.Cat.ID)[1:], postResult.Post.ID, postResult.Post.ThreadID),
User: templates.UserToTemplate(&postResult.User),
Date: postResult.Post.PostDate,
Breadcrumbs: breadcrumbs,
Unread: !hasRead,
Classes: "post-bg-alternate", // TODO: Should this be the default, and the home page can suppress it?
Content: postResult.Post.Preview,
})
} }
c.Perf.EndBlock() c.Perf.EndBlock()
@ -197,8 +159,10 @@ func Feed(c *RequestContext) ResponseData {
res.WriteTemplate("feed.html", FeedData{ res.WriteTemplate("feed.html", FeedData{
BaseData: baseData, BaseData: baseData,
Posts: postItems, AtomFeedUrl: hmnurl.BuildAtomFeed(),
Pagination: pagination, MarkAllReadUrl: hmnurl.BuildMarkRead(0),
Posts: postItems,
Pagination: pagination,
}, c.Perf) }, c.Perf)
return res return res

View File

@ -1,9 +1,7 @@
package website package website
import ( import (
"context"
"errors" "errors"
"fmt"
"math" "math"
"net/http" "net/http"
"strconv" "strconv"
@ -16,13 +14,13 @@ import (
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/utils" "git.handmade.network/hmn/hmn/src/utils"
"github.com/jackc/pgx/v4/pgxpool"
) )
type forumCategoryData struct { type forumCategoryData struct {
templates.BaseData templates.BaseData
CategoryUrl string NewThreadUrl string
MarkReadUrl string
Threads []templates.ThreadListItem Threads []templates.ThreadListItem
Pagination templates.Pagination Pagination templates.Pagination
Subcategories []forumSubcategoryData Subcategories []forumSubcategoryData
@ -38,19 +36,17 @@ type forumSubcategoryData struct {
func ForumCategory(c *RequestContext) ResponseData { func ForumCategory(c *RequestContext) ResponseData {
const threadsPerPage = 25 const threadsPerPage = 25
// TODO(asaf): Consider making this more robust. c.Perf.StartBlock("SQL", "Fetch category tree")
// Right now this code allows for weird urls like: categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
// "/forums/asdf/wip" which doesn't verify the lineage and displays the wip forums lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
// "/forums/wip///" which fetches the main forums page because it happens to have a blank slug c.Perf.EndBlock()
// "/forums/wip/" which fetches the main forums page because Split returns an extra blank string
// "/forums/wip/1" this one fetches the wip forums because the regex matches the `/1` as part of the page group currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
// "/forums/wip/1/" 404 - doesn't match the regex if !valid {
// "/forums/" 404 - doesn't match the regex return FourOhFour(c)
catPath := c.PathParams["cats"] }
catSlugs := strings.Split(catPath, "/")
currentCatId := fetchCatIdFromSlugs(c.Context(), c.Conn, catSlugs, c.CurrentProject.ID) currentSubforumSlugs := lineageBuilder.GetSubforumLineageSlugs(currentCatId)
// TODO(asaf): 404 if we can't find our cat.
categoryUrls := GetProjectCategoryUrls(c.Context(), c.Conn, c.CurrentProject.ID)
c.Perf.StartBlock("SQL", "Fetch count of page threads") c.Perf.StartBlock("SQL", "Fetch count of page threads")
numThreads, err := db.QueryInt(c.Context(), c.Conn, numThreads, err := db.QueryInt(c.Context(), c.Conn,
@ -76,11 +72,11 @@ func ForumCategory(c *RequestContext) ResponseData {
if pageParsed, err := strconv.Atoi(pageString); err == nil { if pageParsed, err := strconv.Atoi(pageString); err == nil {
page = pageParsed page = pageParsed
} else { } else {
return c.Redirect("/feed", http.StatusSeeOther) // TODO return c.Redirect(hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, 1), http.StatusSeeOther)
} }
} }
if page < 1 || numPages < page { if page < 1 || numPages < page {
return c.Redirect("/feed", http.StatusSeeOther) // TODO return c.Redirect(hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page, numPages)), http.StatusSeeOther)
} }
howManyThreadsToSkip := (page - 1) * threadsPerPage howManyThreadsToSkip := (page - 1) * threadsPerPage
@ -143,10 +139,8 @@ func ForumCategory(c *RequestContext) ResponseData {
} }
return templates.ThreadListItem{ return templates.ThreadListItem{
Title: row.Thread.Title, Title: row.Thread.Title,
// TODO(asaf): Use thread.category_id instead of currentCatId. At the moment this is generating wrong urls for threads in subcats. Url: hmnurl.BuildForumThread(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(row.Thread.CategoryID), row.Thread.ID, row.Thread.Title, 1),
Url: ThreadUrl(row.Thread, models.CatKindForum, categoryUrls[currentCatId]),
FirstUser: templates.UserToTemplate(row.FirstUser), FirstUser: templates.UserToTemplate(row.FirstUser),
FirstDate: row.FirstPost.PostDate, FirstDate: row.FirstPost.PostDate,
LastUser: templates.UserToTemplate(row.LastUser), LastUser: templates.UserToTemplate(row.LastUser),
@ -168,29 +162,9 @@ func ForumCategory(c *RequestContext) ResponseData {
var subcats []forumSubcategoryData var subcats []forumSubcategoryData
if page == 1 { if page == 1 {
c.Perf.StartBlock("SQL", "Fetch subcategories") subcatNodes := categoryTree[currentCatId].Children
type subcatQueryResult struct {
Cat models.Category `db:"cat"`
}
itSubcats, err := db.Query(c.Context(), c.Conn, subcatQueryResult{},
`
SELECT $columns
FROM
handmade_category AS cat
WHERE
cat.parent_id = $1
`,
currentCatId,
)
if err != nil {
panic(oops.New(err, "failed to fetch subcategories"))
}
defer itSubcats.Close()
c.Perf.EndBlock()
for _, irow := range itSubcats.ToSlice() {
catRow := irow.(*subcatQueryResult)
for _, catNode := range subcatNodes {
c.Perf.StartBlock("SQL", "Fetch count of subcategory threads") 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. // TODO(asaf): [PERF] [MINOR] Consider replacing querying count per subcat with a single query for all cats with GROUP BY.
numThreads, err := db.QueryInt(c.Context(), c.Conn, numThreads, err := db.QueryInt(c.Context(), c.Conn,
@ -201,7 +175,7 @@ func ForumCategory(c *RequestContext) ResponseData {
thread.category_id = $1 thread.category_id = $1
AND NOT thread.deleted AND NOT thread.deleted
`, `,
catRow.Cat.ID, catNode.ID,
) )
if err != nil { if err != nil {
panic(oops.New(err, "failed to get count of threads")) panic(oops.New(err, "failed to get count of threads"))
@ -233,7 +207,7 @@ func ForumCategory(c *RequestContext) ResponseData {
ORDER BY lastpost.postdate DESC ORDER BY lastpost.postdate DESC
LIMIT 3 LIMIT 3
`, `,
catRow.Cat.ID, catNode.ID,
currentUserId, currentUserId,
) )
if err != nil { if err != nil {
@ -249,8 +223,8 @@ func ForumCategory(c *RequestContext) ResponseData {
} }
subcats = append(subcats, forumSubcategoryData{ subcats = append(subcats, forumSubcategoryData{
Name: *catRow.Cat.Name, Name: *catNode.Name,
Url: categoryUrls[catRow.Cat.ID], Url: hmnurl.BuildForumCategory(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(catNode.ID), 1),
Threads: threads, Threads: threads,
TotalThreads: numThreads, TotalThreads: numThreads,
}) })
@ -266,28 +240,37 @@ func ForumCategory(c *RequestContext) ResponseData {
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 subcategories.
{ {
Name: c.CurrentProject.Name, Name: c.CurrentProject.Name,
Url: hmnurl.ProjectUrl("/", nil, c.CurrentProject.Slug), Url: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
}, },
{ {
Name: "Forums", Name: "Forums",
Url: categoryUrls[currentCatId], Url: hmnurl.BuildForumCategory(c.CurrentProject.Slug, nil, 1),
Current: true, Current: true,
}, },
} }
currentSubforums := lineageBuilder.GetSubforumLineage(currentCatId)
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),
})
}
var res ResponseData var res ResponseData
err = res.WriteTemplate("forum_category.html", forumCategoryData{ err = res.WriteTemplate("forum_category.html", forumCategoryData{
BaseData: baseData, BaseData: baseData,
CategoryUrl: categoryUrls[currentCatId], NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs),
Threads: threads, MarkReadUrl: hmnurl.BuildMarkRead(currentCatId),
Threads: threads,
Pagination: templates.Pagination{ Pagination: templates.Pagination{
Current: page, Current: page,
Total: numPages, Total: numPages,
FirstUrl: categoryUrls[currentCatId], FirstUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, 1),
LastUrl: fmt.Sprintf("%s/%d", categoryUrls[currentCatId], numPages), LastUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, numPages),
NextUrl: fmt.Sprintf("%s/%d", categoryUrls[currentCatId], page+1), NextUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page+1, numPages)),
PreviousUrl: fmt.Sprintf("%s/%d", categoryUrls[currentCatId], page-1), PreviousUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page-1, numPages)),
}, },
Subcategories: subcats, Subcategories: subcats,
}, c.Perf) }, c.Perf)
@ -312,11 +295,23 @@ type forumThreadData struct {
func ForumThread(c *RequestContext) ResponseData { func ForumThread(c *RequestContext) ResponseData {
const postsPerPage = 15 const postsPerPage = 15
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
if !valid {
return FourOhFour(c)
}
threadId, err := strconv.Atoi(c.PathParams["threadid"]) threadId, err := strconv.Atoi(c.PathParams["threadid"])
if err != nil { if err != nil {
return FourOhFour(c) return FourOhFour(c)
} }
currentSubforumSlugs := lineageBuilder.GetSubforumLineageSlugs(currentCatId)
c.Perf.StartBlock("SQL", "Fetch current thread") c.Perf.StartBlock("SQL", "Fetch current thread")
type threadQueryResult struct { type threadQueryResult struct {
Thread models.Thread `db:"thread"` Thread models.Thread `db:"thread"`
@ -330,10 +325,10 @@ func ForumThread(c *RequestContext) ResponseData {
WHERE WHERE
thread.id = $1 thread.id = $1
AND NOT thread.deleted AND NOT thread.deleted
AND cat.project_id = $2 AND cat.id = $2
`, `,
threadId, threadId,
c.CurrentProject.ID, currentCatId, // NOTE(asaf): This verifies that the requested thread is under the requested subforum.
) )
c.Perf.EndBlock() c.Perf.EndBlock()
if err != nil { if err != nil {
@ -345,14 +340,6 @@ func ForumThread(c *RequestContext) ResponseData {
} }
thread := irow.(*threadQueryResult).Thread thread := irow.(*threadQueryResult).Thread
categoryUrls := GetProjectCategoryUrls(c.Context(), c.Conn, c.CurrentProject.ID)
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
subforums := lineageBuilder.GetLineageSlugs(thread.CategoryID)[1:]
c.Perf.EndBlock()
numPosts, err := db.QueryInt(c.Context(), c.Conn, numPosts, err := db.QueryInt(c.Context(), c.Conn,
` `
SELECT COUNT(*) SELECT COUNT(*)
@ -368,17 +355,17 @@ func ForumThread(c *RequestContext) ResponseData {
} }
page, numPages, ok := getPageInfo(c.PathParams["page"], numPosts, postsPerPage) page, numPages, ok := getPageInfo(c.PathParams["page"], numPosts, postsPerPage)
if !ok { if !ok {
urlNoPage := ThreadUrl(thread, models.CatKindForum, categoryUrls[thread.CategoryID]) urlNoPage := hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, 1)
return c.Redirect(urlNoPage, http.StatusSeeOther) return c.Redirect(urlNoPage, http.StatusSeeOther)
} }
pagination := templates.Pagination{ pagination := templates.Pagination{
Current: page, Current: page,
Total: numPages, Total: numPages,
FirstUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, subforums, thread.ID, 1), FirstUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, 1),
LastUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, subforums, thread.ID, numPages), LastUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, numPages),
NextUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, subforums, thread.ID, utils.IntClamp(1, page+1, numPages)), NextUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page+1, numPages)),
PreviousUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, subforums, thread.ID, utils.IntClamp(1, page-1, numPages)), PreviousUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page-1, numPages)),
} }
c.Perf.StartBlock("SQL", "Fetch posts") c.Perf.StartBlock("SQL", "Fetch posts")
@ -418,7 +405,7 @@ func ForumThread(c *RequestContext) ResponseData {
post := templates.PostToTemplate(&row.Post, row.Author) post := templates.PostToTemplate(&row.Post, row.Author)
post.AddContentVersion(row.Ver, row.Editor) post.AddContentVersion(row.Ver, row.Editor)
post.AddUrls(c.CurrentProject.Slug, subforums, thread.ID, post.ID) post.AddUrls(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, post.ID)
posts = append(posts, post) posts = append(posts, post)
} }
@ -432,8 +419,8 @@ func ForumThread(c *RequestContext) ResponseData {
BaseData: baseData, BaseData: baseData,
Thread: templates.ThreadToTemplate(&thread), Thread: templates.ThreadToTemplate(&thread),
Posts: posts, Posts: posts,
CategoryUrl: categoryUrls[thread.CategoryID], CategoryUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, 1),
ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, subforums, thread.ID, *thread.FirstID), ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, *thread.FirstID),
Pagination: pagination, Pagination: pagination,
}, c.Perf) }, c.Perf)
if err != nil { if err != nil {
@ -443,46 +430,36 @@ func ForumThread(c *RequestContext) ResponseData {
return res return res
} }
func fetchCatIdFromSlugs(ctx context.Context, conn *pgxpool.Pool, catSlugs []string, projectId int) int { func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, catPath string) (int, bool) {
if len(catSlugs) == 1 { if project.ForumID == nil {
var err error return -1, false
currentCatId, err := db.QueryInt(ctx, conn,
`
SELECT cat.id
FROM
handmade_category AS cat
JOIN handmade_project AS proj ON proj.forum_id = cat.id
WHERE
proj.id = $1
AND cat.kind = $2
`,
projectId,
models.CatKindForum,
)
if err != nil {
panic(oops.New(err, "failed to get root category id"))
}
return currentCatId
} else {
var err error
currentCatId, err := db.QueryInt(ctx, conn,
`
SELECT id
FROM handmade_category
WHERE
slug = $1
AND kind = $2
AND project_id = $3
`,
catSlugs[len(catSlugs)-1],
models.CatKindForum,
projectId,
)
if err != nil {
panic(oops.New(err, "failed to get current category id"))
}
return currentCatId
} }
subforumCatId := *project.ForumID
if len(catPath) == 0 {
return subforumCatId, true
}
catPath = strings.ToLower(catPath)
valid := false
catSlugs := strings.Split(catPath, "/")
lastSlug := catSlugs[len(catSlugs)-1]
if len(lastSlug) > 0 {
lastSlugCatId := lineageBuilder.FindIdBySlug(project.ID, lastSlug)
if lastSlugCatId != -1 {
subforumSlugs := lineageBuilder.GetSubforumLineageSlugs(lastSlugCatId)
allMatch := true
for i, subforum := range subforumSlugs {
if subforum != catSlugs[i] {
allMatch = false
break
}
}
valid = allMatch
}
if valid {
subforumCatId = lastSlugCatId
}
}
return subforumCatId, valid
} }

View File

@ -7,6 +7,7 @@ import (
"time" "time"
"git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/templates"
@ -18,12 +19,21 @@ type LandingTemplateData struct {
NewsPost LandingPageFeaturedPost NewsPost LandingPageFeaturedPost
PostColumns [][]LandingPageProject PostColumns [][]LandingPageProject
ShowcaseTimelineJson string ShowcaseTimelineJson string
FeedUrl string
PodcastUrl string
StreamsUrl string
IRCUrl string
DiscordUrl string
ShowUrl string
ShowcaseUrl string
} }
type LandingPageProject struct { type LandingPageProject struct {
Project templates.Project Project templates.Project
FeaturedPost *LandingPageFeaturedPost FeaturedPost *LandingPageFeaturedPost
Posts []templates.PostListItem Posts []templates.PostListItem
ForumsUrl string
} }
type LandingPageFeaturedPost struct { type LandingPageFeaturedPost struct {
@ -45,11 +55,12 @@ func Index(c *RequestContext) ResponseData {
SELECT $columns SELECT $columns
FROM handmade_project FROM handmade_project
WHERE WHERE
flags = 0 (flags = 0 AND NOT lifecycle = ANY($1))
OR id = $1 OR id = $2
ORDER BY all_last_updated DESC ORDER BY all_last_updated DESC
LIMIT $2 LIMIT $3
`, `,
[]models.ProjectLifecycle{models.ProjectLifecycleUnapproved, models.ProjectLifecycleApprovalRequired},
models.HMNProjectID, models.HMNProjectID,
numProjectsToGet*2, // hedge your bets against projects that don't have any content numProjectsToGet*2, // hedge your bets against projects that don't have any content
) )
@ -62,9 +73,11 @@ func Index(c *RequestContext) ResponseData {
allProjects := iterProjects.ToSlice() allProjects := iterProjects.ToSlice()
c.Perf.EndBlock() c.Perf.EndBlock()
c.Logger.Debug().Interface("allProjects", allProjects).Msg("all the projects")
categoryUrls := GetAllCategoryUrls(c.Context(), c.Conn) c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
var currentUserId *int var currentUserId *int
if c.CurrentUser != nil { if c.CurrentUser != nil {
@ -77,11 +90,12 @@ func Index(c *RequestContext) ResponseData {
c.Perf.StartBlock("SQL", fmt.Sprintf("Fetch posts for %s", proj.Name)) c.Perf.StartBlock("SQL", fmt.Sprintf("Fetch posts for %s", proj.Name))
type projectPostQuery struct { type projectPostQuery struct {
Post models.Post `db:"post"` Post models.Post `db:"post"`
Thread models.Thread `db:"thread"` Thread models.Thread `db:"thread"`
User models.User `db:"auth_user"` User models.User `db:"auth_user"`
ThreadLastReadTime *time.Time `db:"tlri.lastread"` LibraryResource *models.LibraryResource `db:"lib_resource"`
CatLastReadTime *time.Time `db:"clri.lastread"` ThreadLastReadTime *time.Time `db:"tlri.lastread"`
CatLastReadTime *time.Time `db:"clri.lastread"`
} }
projectPostIter, err := db.Query(c.Context(), c.Conn, projectPostQuery{}, projectPostIter, err := db.Query(c.Context(), c.Conn, projectPostQuery{},
` `
@ -98,6 +112,7 @@ func Index(c *RequestContext) ResponseData {
AND clri.user_id = $1 AND clri.user_id = $1
) )
LEFT JOIN auth_user ON post.author_id = auth_user.id LEFT JOIN auth_user ON post.author_id = auth_user.id
LEFT JOIN handmade_libraryresource as lib_resource ON lib_resource.category_id = post.category_id
WHERE WHERE
post.project_id = $2 post.project_id = $2
AND post.category_kind IN ($3, $4, $5, $6) AND post.category_kind IN ($3, $4, $5, $6)
@ -117,8 +132,16 @@ func Index(c *RequestContext) ResponseData {
} }
projectPosts := projectPostIter.ToSlice() projectPosts := projectPostIter.ToSlice()
forumsUrl := ""
if proj.ForumID != nil {
forumsUrl = hmnurl.BuildForumCategory(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")
}
landingPageProject := LandingPageProject{ landingPageProject := LandingPageProject{
Project: templates.ProjectToTemplate(proj), Project: templates.ProjectToTemplate(proj),
ForumsUrl: forumsUrl,
} }
for _, projectPostRow := range projectPosts { for _, projectPostRow := range projectPosts {
@ -150,28 +173,35 @@ func Index(c *RequestContext) ResponseData {
WHERE WHERE
post.id = $1 post.id = $1
`, projectPost.Post.ID) `, projectPost.Post.ID)
if err != nil {
panic(err)
}
c.Perf.EndBlock() c.Perf.EndBlock()
if err != nil {
c.Logger.Error().Err(err).Msg("failed to fetch featured post content")
continue
}
content := contentResult.(*featuredContentResult).Content content := contentResult.(*featuredContentResult).Content
landingPageProject.FeaturedPost = &LandingPageFeaturedPost{ landingPageProject.FeaturedPost = &LandingPageFeaturedPost{
Title: projectPost.Thread.Title, Title: projectPost.Thread.Title,
Url: PostUrl(projectPost.Post, projectPost.Post.CategoryKind, categoryUrls[projectPost.Post.CategoryID]), Url: hmnurl.BuildBlogPost(proj.Slug, projectPost.Thread.ID, projectPost.Post.ID),
User: templates.UserToTemplate(&projectPost.User), User: templates.UserToTemplate(&projectPost.User),
Date: projectPost.Post.PostDate, Date: projectPost.Post.PostDate,
Unread: !hasRead, Unread: !hasRead,
Content: template.HTML(content), Content: template.HTML(content),
} }
} else { } else {
landingPageProject.Posts = append(landingPageProject.Posts, templates.PostListItem{ landingPageProject.Posts = append(
Title: projectPost.Thread.Title, landingPageProject.Posts,
Url: PostUrl(projectPost.Post, projectPost.Post.CategoryKind, categoryUrls[projectPost.Post.CategoryID]), MakePostListItem(
User: templates.UserToTemplate(&projectPost.User), lineageBuilder,
Date: projectPost.Post.PostDate, proj,
Unread: !hasRead, &projectPost.Thread,
}) &projectPost.Post,
&projectPost.User,
projectPost.LibraryResource,
!hasRead,
false,
),
)
} }
} }
@ -185,30 +215,6 @@ func Index(c *RequestContext) ResponseData {
} }
c.Perf.EndBlock() c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Get news")
type newsThreadQuery struct {
Thread models.Thread `db:"thread"`
}
newsThreadRow, err := db.QueryOne(c.Context(), c.Conn, newsThreadQuery{},
`
SELECT $columns
FROM
handmade_thread as thread
JOIN handmade_category AS cat ON thread.category_id = cat.id
WHERE
cat.project_id = $1
AND cat.kind = $2
`,
models.HMNProjectID,
models.CatKindBlog,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch latest news post"))
}
c.Perf.EndBlock()
newsThread := newsThreadRow.(*newsThreadQuery)
_ = newsThread // TODO: NO
/* /*
Columns are filled by placing projects into the least full column. Columns are filled by placing projects into the least full column.
The fill array tracks the estimated sizes. The fill array tracks the estimated sizes.
@ -239,6 +245,7 @@ func Index(c *RequestContext) ResponseData {
} }
} }
c.Perf.StartBlock("SQL", "Get news")
type newsPostQuery struct { type newsPostQuery struct {
Post models.Post `db:"post"` Post models.Post `db:"post"`
PostVersion models.PostVersion `db:"ver"` PostVersion models.PostVersion `db:"ver"`
@ -251,12 +258,11 @@ func Index(c *RequestContext) ResponseData {
FROM FROM
handmade_post AS post handmade_post AS post
JOIN handmade_thread AS thread ON post.thread_id = thread.id JOIN handmade_thread AS thread ON post.thread_id = thread.id
JOIN handmade_category AS cat ON thread.category_id = cat.id
JOIN auth_user ON post.author_id = auth_user.id JOIN auth_user ON post.author_id = auth_user.id
JOIN handmade_postversion AS ver ON post.current_id = ver.id JOIN handmade_postversion AS ver ON post.current_id = ver.id
WHERE WHERE
cat.project_id = $1 post.project_id = $1
AND cat.kind = $2 AND post.category_kind = $2
AND post.id = thread.first_id AND post.id = thread.first_id
AND NOT thread.deleted AND NOT thread.deleted
ORDER BY post.postdate DESC ORDER BY post.postdate DESC
@ -269,16 +275,24 @@ func Index(c *RequestContext) ResponseData {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch news post")) return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch news post"))
} }
newsPostResult := newsPostRow.(*newsPostQuery) newsPostResult := newsPostRow.(*newsPostQuery)
c.Perf.EndBlock()
baseData := getBaseData(c) baseData := getBaseData(c)
baseData.BodyClasses = append(baseData.BodyClasses, "hmdev", "landing") // TODO: Is "hmdev" necessary any more? baseData.BodyClasses = append(baseData.BodyClasses, "hmdev", "landing") // TODO: Is "hmdev" necessary any more?
var res ResponseData var res ResponseData
err = res.WriteTemplate("landing.html", LandingTemplateData{ err = res.WriteTemplate("landing.html", LandingTemplateData{
BaseData: baseData, BaseData: baseData,
FeedUrl: hmnurl.BuildFeed(),
PodcastUrl: hmnurl.BuildPodcast(models.HMNProjectSlug),
StreamsUrl: hmnurl.BuildStreams(),
IRCUrl: hmnurl.BuildBlogThread(models.HMNProjectSlug, 1138, "[Tutorial] Handmade Network IRC", 1),
DiscordUrl: "https://discord.gg/hxWxDee",
ShowUrl: "https://handmadedev.show/",
ShowcaseUrl: hmnurl.BuildShowcase(),
NewsPost: LandingPageFeaturedPost{ NewsPost: LandingPageFeaturedPost{
Title: newsPostResult.Thread.Title, Title: newsPostResult.Thread.Title,
Url: PostUrl(newsPostResult.Post, models.CatKindBlog, ""), Url: hmnurl.BuildBlogPost(models.HMNProjectSlug, newsPostResult.Thread.ID, newsPostResult.Post.ID),
User: templates.UserToTemplate(&newsPostResult.User), User: templates.UserToTemplate(&newsPostResult.User),
Date: newsPostResult.Post.PostDate, Date: newsPostResult.Post.PostDate,
Unread: true, // TODO Unread: true, // TODO

View File

@ -0,0 +1,74 @@
package website
import (
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/templates"
)
// 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:
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)
}
return hmnurl.BuildProjectHomepage(projectSlug)
}
// 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) templates.PostListItem {
var result templates.PostListItem
result.Title = thread.Title
result.User = templates.UserToTemplate(user)
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)
if includeBreadcrumbs {
result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{
Name: project.Name,
Url: hmnurl.BuildProjectHomepage(project.Slug),
})
result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{
Name: CategoryKindDisplayNames[post.CategoryKind],
Url: BuildProjectMainCategoryUrl(project.Slug, post.CategoryKind),
})
switch post.CategoryKind {
case models.CatKindForum:
subforums := lineageBuilder.GetSubforumLineage(post.CategoryID)
slugs := lineageBuilder.GetSubforumLineageSlugs(post.CategoryID)
for i, subforum := range subforums {
result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{
Name: *subforum.Name, // NOTE(asaf): All subforum categories must have names.
Url: hmnurl.BuildForumCategory(project.Slug, slugs[0:i+1], 1),
})
}
case models.CatKindLibraryResource:
result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{
Name: libraryResource.Name,
Url: hmnurl.BuildLibraryResource(project.Slug, libraryResource.ID),
})
}
}
return result
}

View File

@ -81,8 +81,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
} }
// TODO(asaf): login/logout shouldn't happen on subdomains. We should verify that in the middleware. // TODO(asaf): login/logout shouldn't happen on subdomains. We should verify that in the middleware.
routes.POST(hmnurl.RegexLogin, Login) routes.POST(hmnurl.RegexLoginAction, Login)
routes.GET(hmnurl.RegexLogout, Logout) routes.GET(hmnurl.RegexLogoutAction, Logout)
routes.StdHandler(hmnurl.RegexPublic, routes.StdHandler(hmnurl.RegexPublic,
http.StripPrefix("/public/", http.FileServer(http.Dir("public"))), http.StripPrefix("/public/", http.FileServer(http.Dir("public"))),
) )
@ -130,9 +130,39 @@ func getBaseData(c *RequestContext) templates.BaseData {
} }
return templates.BaseData{ return templates.BaseData{
Project: templates.ProjectToTemplate(c.CurrentProject), Project: templates.ProjectToTemplate(c.CurrentProject),
User: templateUser, LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()),
Theme: "light", User: templateUser,
Theme: "light",
ProjectCSSUrl: hmnurl.BuildProjectCSS(c.CurrentProject.Color1),
Header: templates.Header{
AdminUrl: hmnurl.BuildHomepage(), // TODO(asaf)
MemberSettingsUrl: hmnurl.BuildHomepage(), // TODO(asaf)
LoginActionUrl: hmnurl.BuildLoginAction(c.FullUrl()),
LogoutActionUrl: hmnurl.BuildLogoutAction(),
RegisterUrl: hmnurl.BuildHomepage(), // TODO(asaf)
HMNHomepageUrl: hmnurl.BuildHomepage(), // TODO(asaf)
ProjectHomepageUrl: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
BlogUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, 1),
ForumsUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, nil, 1),
WikiUrl: hmnurl.BuildWiki(c.CurrentProject.Slug),
LibraryUrl: hmnurl.BuildLibrary(c.CurrentProject.Slug),
ManifestoUrl: hmnurl.BuildManifesto(),
EpisodeGuideUrl: hmnurl.BuildHomepage(), // TODO(asaf)
EditUrl: hmnurl.BuildHomepage(), // TODO(asaf)
SearchActionUrl: hmnurl.BuildHomepage(), // TODO(asaf)
},
Footer: templates.Footer{
HomepageUrl: hmnurl.BuildHomepage(),
AboutUrl: hmnurl.BuildAbout(),
ManifestoUrl: hmnurl.BuildManifesto(),
CodeOfConductUrl: hmnurl.BuildCodeOfConduct(),
CommunicationGuidelinesUrl: hmnurl.BuildCommunicationGuidelines(),
ProjectIndexUrl: hmnurl.BuildProjectIndex(),
ForumsUrl: hmnurl.BuildForumCategory(models.HMNProjectSlug, nil, 1),
ContactUrl: hmnurl.BuildContactPage(),
SitemapUrl: hmnurl.BuildSiteMap(),
},
} }
} }
@ -284,11 +314,11 @@ func TrackRequestPerf(c *RequestContext, perfCollector *perf.PerfCollector) (aft
c.Perf.EndRequest() c.Perf.EndRequest()
log := logging.Info() log := logging.Info()
blockStack := make([]time.Time, 0) blockStack := make([]time.Time, 0)
for _, block := range c.Perf.Blocks { for i, block := range c.Perf.Blocks {
for len(blockStack) > 0 && block.End.After(blockStack[len(blockStack)-1]) { for len(blockStack) > 0 && block.End.After(blockStack[len(blockStack)-1]) {
blockStack = blockStack[:len(blockStack)-1] blockStack = blockStack[:len(blockStack)-1]
} }
log.Str(fmt.Sprintf("At %9.2fms", c.Perf.MsFromStart(&block)), fmt.Sprintf("%*.s[%s] %s (%.4fms)", len(blockStack)*2, "", block.Category, block.Description, block.DurationMs())) log.Str(fmt.Sprintf("[%4.d] At %9.2fms", i, c.Perf.MsFromStart(&block)), fmt.Sprintf("%*.s[%s] %s (%.4fms)", len(blockStack)*2, "", block.Category, block.Description, block.DurationMs()))
blockStack = append(blockStack, block.End) blockStack = append(blockStack, block.End)
} }
log.Msg(fmt.Sprintf("Served %s in %.4fms", c.Perf.Path, float64(c.Perf.End.Sub(c.Perf.Start).Nanoseconds())/1000/1000)) log.Msg(fmt.Sprintf("Served %s in %.4fms", c.Perf.Path, float64(c.Perf.End.Sub(c.Perf.Start).Nanoseconds())/1000/1000))
@ -298,6 +328,6 @@ func TrackRequestPerf(c *RequestContext, perfCollector *perf.PerfCollector) (aft
func LogContextErrors(c *RequestContext, res *ResponseData) { func LogContextErrors(c *RequestContext, res *ResponseData) {
for _, err := range res.Errors { for _, err := range res.Errors {
c.Logger.Error().Err(err).Msg("error occurred during request") c.Logger.Error().Timestamp().Stack().Str("Requested", c.FullUrl()).Err(err).Msg("error occurred during request")
} }
} }

View File

@ -1,136 +1,20 @@
package website package website
import ( import (
"context"
"fmt"
"strings"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/models"
"github.com/jackc/pgx/v4/pgxpool"
) )
type categoryUrlQueryResult struct { func BuildProjectMainCategoryUrl(projectSlug string, kind models.CategoryKind) string {
Cat models.Category `db:"cat"` switch kind {
Project models.Project `db:"project"`
}
func GetAllCategoryUrls(ctx context.Context, conn *pgxpool.Pool) map[int]string {
it, err := db.Query(ctx, conn, categoryUrlQueryResult{},
`
SELECT $columns
FROM
handmade_category AS cat
JOIN handmade_project AS project ON project.id = cat.project_id
WHERE
cat.kind != 6
`, // TODO(asaf): Clean up the db and remove the cat.kind != 6 check
)
if err != nil {
panic(err)
}
defer it.Close()
return makeCategoryUrls(it.ToSlice())
}
func GetProjectCategoryUrls(ctx context.Context, conn *pgxpool.Pool, projectId ...int) map[int]string {
it, err := db.Query(ctx, conn, categoryUrlQueryResult{},
`
SELECT $columns
FROM
handmade_category AS cat
JOIN handmade_project AS project ON project.id = cat.project_id
WHERE
project.id = ANY ($1)
AND cat.kind != $2
`, // TODO(asaf): Clean up the db and remove the cat.kind != library resource check
projectId,
models.CatKindLibraryResource,
)
if err != nil {
panic(err)
}
defer it.Close()
return makeCategoryUrls(it.ToSlice())
}
func makeCategoryUrls(rows []interface{}) map[int]string {
categories := make(map[int]*models.Category)
for _, irow := range rows {
cat := irow.(*categoryUrlQueryResult).Cat
categories[cat.ID] = &cat
}
result := make(map[int]string)
for _, irow := range rows {
row := irow.(*categoryUrlQueryResult)
// get hierarchy (backwards, so current -> parent -> root)
var hierarchyReverse []*models.Category
currentCatID := row.Cat.ID
for {
cat := categories[currentCatID]
hierarchyReverse = append(hierarchyReverse, cat)
if cat.ParentID == nil {
break
} else {
currentCatID = *cat.ParentID
}
}
// reverse to get root -> parent -> current
hierarchy := make([]*models.Category, len(hierarchyReverse))
for i := len(hierarchyReverse) - 1; i >= 0; i-- {
hierarchy[len(hierarchyReverse)-1-i] = hierarchyReverse[i]
}
result[row.Cat.ID] = CategoryUrl(row.Project.Slug, hierarchy...)
}
return result
}
func CategoryUrl(projectSlug string, cats ...*models.Category) string {
catSlugs := make([]string, 0, len(cats))
for _, cat := range cats {
catSlugs = append(catSlugs, *cat.Slug)
}
switch cats[0].Kind {
case models.CatKindForum:
return hmnurl.BuildForumCategory(projectSlug, catSlugs[1:], 1)
default:
return ""
}
}
func PostUrl(post models.Post, catKind models.CategoryKind, categoryUrl string) string {
categoryUrl = strings.TrimRight(categoryUrl, "/")
switch catKind {
// TODO: All the relevant post types. Maybe it doesn't make sense to lump them all together here.
case models.CatKindBlog: case models.CatKindBlog:
return fmt.Sprintf("%s/p/%d/e/%d", categoryUrl, post.ThreadID, post.ID) return hmnurl.BuildBlog(projectSlug, 1)
case models.CatKindForum: case models.CatKindForum:
return fmt.Sprintf("%s/t/%d/p/%d", categoryUrl, post.ThreadID, post.ID) return hmnurl.BuildForumCategory(projectSlug, nil, 1)
case models.CatKindWiki:
return hmnurl.BuildWiki(projectSlug)
case models.CatKindLibraryResource:
return hmnurl.BuildLibrary(projectSlug)
} }
return hmnurl.BuildProjectHomepage(projectSlug)
return ""
}
func ThreadUrl(thread models.Thread, catKind models.CategoryKind, categoryUrl string) string {
categoryUrl = strings.TrimRight(categoryUrl, "/")
switch catKind {
// TODO: All the relevant post types. Maybe it doesn't make sense to lump them all together here.
case models.CatKindBlog:
return fmt.Sprintf("%s/p/%d", categoryUrl, thread.ID)
case models.CatKindForum:
return fmt.Sprintf("%s/t/%d", categoryUrl, thread.ID)
}
return ""
} }