From 02938bbf2c3ea8bc78293dc6cc5e9d7255e5e45b Mon Sep 17 00:00:00 2001 From: Asaf Gartner Date: Wed, 12 May 2021 01:53:23 +0300 Subject: [PATCH] URL rework! --- src/hmnurl/hmnurl.go | 13 + src/hmnurl/hmnurl_test.go | 283 +++++++++- src/hmnurl/urls.go | 732 +++++++++++++++++++++++--- src/models/category.go | 29 +- src/models/library_resource.go | 16 + src/models/project.go | 4 +- src/templates/mapping.go | 1 + src/templates/src/feed.html | 4 +- src/templates/src/forum_category.html | 6 +- src/templates/src/forum_thread.html | 2 +- src/templates/src/include/footer.html | 20 +- src/templates/src/include/header.html | 32 +- src/templates/src/landing.html | 20 +- src/templates/src/layouts/base.html | 2 +- src/templates/types.go | 37 ++ src/website/category_helper.go | 14 + src/website/feed.go | 96 ++-- src/website/forums.go | 211 ++++---- src/website/landing.go | 116 ++-- src/website/post_helper.go | 74 +++ src/website/routes.go | 46 +- src/website/urls.go | 134 +---- 22 files changed, 1389 insertions(+), 503 deletions(-) create mode 100644 src/models/library_resource.go create mode 100644 src/website/category_helper.go create mode 100644 src/website/post_helper.go diff --git a/src/hmnurl/hmnurl.go b/src/hmnurl/hmnurl.go index 07135421..f25ab710 100644 --- a/src/hmnurl/hmnurl.go +++ b/src/hmnurl/hmnurl.go @@ -1,7 +1,9 @@ package hmnurl import ( + "fmt" "net/url" + "time" "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/models" @@ -17,9 +19,11 @@ type Q struct { } var baseUrlParsed url.URL +var cacheBust string func init() { SetGlobalBaseUrl(config.Config.BaseUrl) + SetCacheBust(fmt.Sprint(time.Now().Unix())) } func SetGlobalBaseUrl(fullBaseUrl string) { @@ -34,11 +38,19 @@ func SetGlobalBaseUrl(fullBaseUrl string) { baseUrlParsed = *parsed } +func SetCacheBust(newCacheBust string) { + cacheBust = newCacheBust +} + func Url(path string, query []Q) string { return ProjectUrl(path, query, "") } 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 if slug == models.HMNProjectSlug { subdomain = "" @@ -54,6 +66,7 @@ func ProjectUrl(path string, query []Q, slug string) string { Host: host, Path: trim(path), RawQuery: encodeQuery(query), + Fragment: fragment, } return url.String() diff --git a/src/hmnurl/hmnurl_test.go b/src/hmnurl/hmnurl_test.go index 69777cd3..0652494b 100644 --- a/src/hmnurl/hmnurl_test.go +++ b/src/hmnurl/hmnurl_test.go @@ -27,14 +27,32 @@ func TestUrl(t *testing.T) { func TestHomepage(t *testing.T) { AssertRegexMatch(t, BuildHomepage(), RegexHomepage, nil) + AssertRegexMatch(t, BuildProjectHomepage("hero"), RegexHomepage, nil) + AssertSubdomain(t, BuildProjectHomepage("hero"), "hero") } -func TestLogin(t *testing.T) { - AssertRegexMatch(t, BuildLogin(), RegexLogin, nil) +func TestProjectIndex(t *testing.T) { + AssertRegexMatch(t, BuildProjectIndex(), RegexProjectIndex, nil) } -func TestLogout(t *testing.T) { - AssertRegexMatch(t, BuildLogout(), RegexLogout, nil) +func TestSiteMap(t *testing.T) { + 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) { @@ -47,6 +65,10 @@ func TestStaticPages(t *testing.T) { 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) { AssertRegexMatch(t, BuildFeed(), RegexFeed, nil) assert.Equal(t, BuildFeed(), BuildFeedWithPage(1)) @@ -57,30 +79,236 @@ func TestFeed(t *testing.T) { 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) { 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{"sub", "wip"}, 2), RegexForumCategory, map[string]string{"cats": "forums/sub/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": "sub/wip", "page": "2"}) AssertSubdomain(t, BuildForumCategory("hmn", nil, 1), "") AssertSubdomain(t, BuildForumCategory("", nil, 1), "") AssertSubdomain(t, BuildForumCategory("hero", nil, 1), "hero") + assert.Panics(t, func() { BuildForumCategory("", nil, 0) }) assert.Panics(t, func() { BuildForumCategory("", []string{"", "wip"}, 1) }) assert.Panics(t, func() { BuildForumCategory("", []string{" ", "wip"}, 1) }) assert.Panics(t, func() { BuildForumCategory("", []string{"wip/jobs"}, 1) }) } +func 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) { AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil) } @@ -96,6 +324,10 @@ func TestPublic(t *testing.T) { 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) { parsed, err := url.Parse(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()) +} diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 5e6cbf3e..7786a7ac 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -1,6 +1,7 @@ package hmnurl import ( + "net/url" "regexp" "strconv" "strings" @@ -8,24 +9,70 @@ import ( "git.handmade.network/hmn/hmn/src/oops" ) +// TODO(asaf): Make this whole file only crash in Dev + var RegexHomepage = regexp.MustCompile("^/$") func BuildHomepage() string { return Url("/", nil) } -var RegexLogin = regexp.MustCompile("^/login$") - -func BuildLogin() string { - return Url("/login", nil) +func BuildProjectHomepage(projectSlug string) string { + return ProjectUrl("/", nil, projectSlug) } -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) } +/* +* Static Pages + */ + var RegexManifesto = regexp.MustCompile("^/manifesto$") func BuildManifesto() string { @@ -68,6 +115,23 @@ func BuildProjectSubmissionGuidelines() string { return Url("/project-guidelines", nil) } +/* +* Member + */ + +var RegexMember = regexp.MustCompile(`^/m/(?P[^/]+)$`) + +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.+)?)?$`) func BuildFeed() string { @@ -84,56 +148,31 @@ func BuildFeedWithPage(page int) string { return Url("/feed/"+strconv.Itoa(page), nil) } -var RegexForumThread = regexp.MustCompile(`^/(?Pforums(/[^\d]+?)*)/t/(?P\d+)(/(?P\d+))?$`) +/* +* Podcast + */ -func BuildForumThread(projectSlug string, subforums []string, threadId int, page int) string { - if page < 1 { - panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page)) - } +var RegexPodcast = regexp.MustCompile(`^/podcast$`) - var builder strings.Builder - 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) - } - builder.WriteString("/t/") - builder.WriteString(strconv.Itoa(threadId)) - if page > 1 { - builder.WriteRune('/') - builder.WriteString(strconv.Itoa(page)) - } - - return ProjectUrl(builder.String(), nil, projectSlug) +func BuildPodcast(projectSlug string) string { + return ProjectUrl("/podcast", nil, projectSlug) } -var RegexForumCategory = regexp.MustCompile(`^/(?Pforums(/[^\d]+?)*)(/(?P\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[^\d/]+(/[^\d]+)*))?(/(?P\d+))?$`) func BuildForumCategory(projectSlug string, subforums []string, page int) string { if page < 1 { panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page)) } - var builder strings.Builder - 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) - } + builder := buildForumCategoryPath(subforums) + if page > 1 { builder.WriteRune('/') builder.WriteString(strconv.Itoa(page)) @@ -142,55 +181,387 @@ func BuildForumCategory(projectSlug string, subforums []string, page int) string return ProjectUrl(builder.String(), nil, projectSlug) } -var RegexForumPost = regexp.MustCompile(``) // TODO(asaf): Complete this and test it +var RegexForumNewThread = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/new?$`) -func BuildForumPost(projectSlug string, subforums []string, threadId int, postId int) string { - var builder strings.Builder - 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) - } - builder.WriteString("/t/") - builder.WriteString(strconv.Itoa(threadId)) - builder.WriteString("/p/") - builder.WriteString(strconv.Itoa(postId)) +func BuildForumNewThread(projectSlug string, subforums []string) string { + builder := buildForumCategoryPath(subforums) + builder.WriteString("/new") return ProjectUrl(builder.String(), nil, projectSlug) } -var RegexForumPostDelete = regexp.MustCompile(``) // TODO +var RegexForumThread = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/t/(?P\d+)(-([^/]+))?(/(?P\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[^\d/]+(/[^\d]+)*))?/t/(?P\d+)/p/(?P\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[^\d/]+(/[^\d]+)*))?/t/(?P\d+)/p/(?P\d+)/delete$`) 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[^\d/]+(/[^\d]+)*))?/t/(?P\d+)/p/(?P\d+)/edit$`) 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[^\d/]+(/[^\d]+)*))?/t/(?P\d+)/p/(?P\d+)/reply$`) // TODO: It's kinda weird that we have "replies" to a specific post. That's not how the data works. I guess this just affects what you see as the "post you're replying to" on the post composer page? func BuildForumPostReply(projectSlug string, subforums []string, threadId int, postId int) string { - 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[^\d/]+(/[^\d]+)*))?/t/(?P\d+)/p/(?P\d+)/quote$`) 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\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\d+)(-([^/]+))?(/(?P\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\d+)/e/(?P\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\d+)/e/(?P\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\d+)/e/(?P\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\d+)/e/(?P\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\d+)/e/(?P\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\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\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\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\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\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\d+)(-([^/])+)?/(?P\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\d+)(-([^/])+)?/diff/(?P\d+)/(?P\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\d+)/talk/(?P\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\d+)/talk/(?P\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\d+)/talk/(?P\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\d+)/talk/(?P\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\d+)/talk/(?P\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\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\d+)$`) + +func BuildLibraryResource(projectSlug string, resourceId int) string { + builder := buildLibraryResourcePath(resourceId) + + return ProjectUrl(builder.String(), nil, projectSlug) +} + +var RegexLibraryDiscussion = regexp.MustCompile(`^/library/resource/(?P\d+)/d/(?P\d+)(/(?P\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\d+)/d/(?P\d+)/p/(?P\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\d+)/d/(?P\d+)/p/(?P\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\d+)/d/(?P\d+)/p/(?P\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\d+)/d/(?P\d+)/p/(?P\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\d+)/d/(?P\d+)/p/(?P\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$") func BuildProjectCSS(color string) string { @@ -218,4 +589,219 @@ func BuildPublic(filepath string) string { return Url(builder.String(), nil) } +/* +* Other + */ + +var RegexMarkRead = regexp.MustCompile(`^/_markread/(?P\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("") + +/* +* 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 +} diff --git a/src/models/category.go b/src/models/category.go index 0972b955..009c6718 100644 --- a/src/models/category.go +++ b/src/models/category.go @@ -38,7 +38,8 @@ type CategoryTree map[int]*CategoryTreeNode type CategoryTreeNode struct { Category - Parent *CategoryTreeNode + Parent *CategoryTreeNode + Children []*CategoryTreeNode } func (node *CategoryTreeNode) GetLineage() []*Category { @@ -84,6 +85,15 @@ func GetFullCategoryTree(ctx context.Context, conn *pgxpool.Pool) CategoryTree { 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 } @@ -109,6 +119,10 @@ func (cl *CategoryLineageBuilder) GetLineage(catId int) []*Category { return cl.CategoryCache[catId] } +func (cl *CategoryLineageBuilder) GetSubforumLineage(catId int) []*Category { + return cl.GetLineage(catId)[1:] +} + func (cl *CategoryLineageBuilder) GetLineageSlugs(catId int) []string { _, ok := cl.SlugCache[catId] if !ok { @@ -125,3 +139,16 @@ func (cl *CategoryLineageBuilder) GetLineageSlugs(catId int) []string { } 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 +} diff --git a/src/models/library_resource.go b/src/models/library_resource.go new file mode 100644 index 00000000..1f0d13eb --- /dev/null +++ b/src/models/library_resource.go @@ -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"` +} diff --git a/src/models/project.go b/src/models/project.go index 95913664..d852a24f 100644 --- a/src/models/project.go +++ b/src/models/project.go @@ -27,12 +27,14 @@ const ( type Project struct { ID int `db:"id"` + ForumID *int `db:"forum_id"` + Slug string `db:"slug"` Name string `db:"name"` Blurb string `db:"blurb"` 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"` Color2 string `db:"color_2"` diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 4dcab5ed..9d3b3070 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -57,6 +57,7 @@ func ProjectToTemplate(p *models.Project) Project { Subdomain: p.Subdomain(), Color1: p.Color1, Color2: p.Color2, + Url: hmnurl.BuildProjectHomepage(p.Slug), IsHMN: p.IsHMN(), diff --git a/src/templates/src/feed.html b/src/templates/src/feed.html index 9caa33a5..cbeb6148 100644 --- a/src/templates/src/feed.html +++ b/src/templates/src/feed.html @@ -10,9 +10,9 @@
diff --git a/src/templates/src/forum_category.html b/src/templates/src/forum_category.html index d98ee9b3..da2545f8 100644 --- a/src/templates/src/forum_category.html +++ b/src/templates/src/forum_category.html @@ -35,11 +35,11 @@ {{ define "forum_category_options" }}
{{ if .User }} - + New Thread + + New Thread {{/* TODO(asaf): Mark read should probably be a POST, since it's destructive and we would probably want CSRF for it */}} - Mark threads here as read + Mark threads here as read {{ else }} - Log in to post a new thread + Log in to post a new thread {{ end }}
diff --git a/src/templates/src/forum_thread.html b/src/templates/src/forum_thread.html index c2b34ae1..834ae4a6 100644 --- a/src/templates/src/forum_thread.html +++ b/src/templates/src/forum_thread.html @@ -120,7 +120,7 @@ {{ else if .User }} ⤷ Reply to Thread {{ else }} - Log in to reply + Log in to reply {{ end }}
diff --git a/src/templates/src/include/footer.html b/src/templates/src/include/footer.html index e1be899d..f1c51370 100644 --- a/src/templates/src/include/footer.html +++ b/src/templates/src/include/footer.html @@ -1,35 +1,35 @@ diff --git a/src/templates/src/include/header.html b/src/templates/src/include/header.html index 53ec3fc9..9951a0e3 100644 --- a/src/templates/src/include/header.html +++ b/src/templates/src/include/header.html @@ -3,15 +3,15 @@
{{ if .User }} {{ if .User.IsSuperuser }} - Admin + Admin {{ end }} - {{ .User.Username }} - Logout + {{ .User.Username }} + Logout {{ else }} - Register - + Register +
-
+ {{/* TODO: CSRF */}} @@ -33,41 +33,41 @@ @@ -78,7 +78,7 @@ {{ $c1 := hex2color $proj.Color1 }}

{{ $proj.Name }}

@@ -92,7 +92,7 @@ {{ end }} @@ -113,7 +113,7 @@

Community Showcase

@@ -129,7 +129,7 @@
- This is a selection of recent work done by community members. Want to participate? Join us on Discord. + This is a selection of recent work done by community members. Want to participate? Join us on Discord.