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
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()

View File

@ -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())
}

View File

@ -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<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>.+)?)?$`)
func BuildFeed() string {
@ -84,56 +148,31 @@ func BuildFeedWithPage(page int) string {
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 {
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(`^/(?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 {
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<cats>[^\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<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 {
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 {
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?
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 {
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$")
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<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("")
/*
* 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 {
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
}

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 {
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"`

View File

@ -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(),

View File

@ -10,9 +10,9 @@
<div class="content-block">
<div class="optionbar">
<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 }}
<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 }}
</div>
<div class="options">

View File

@ -35,11 +35,11 @@
{{ define "forum_category_options" }}
<div class="options">
{{ 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 */}}
<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 }}
<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 }}
</div>
<div class="options">

View File

@ -120,7 +120,7 @@
{{ else if .User }}
<a class="button" href="{{ .ReplyUrl }}">&#10551; Reply to Thread</a>
{{ 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 }}
</div>
<div class="options order-0 order-last-ns">

View File

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

View File

@ -3,15 +3,15 @@
<div class="user-options flex justify-center justify-end-ns">
{{ if .User }}
{{ 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 }}
<a class="username settings" href="{{ url "/member_settings" }}"><span class="icon-settings"></span> {{ .User.Username }}</a>
<a class="logout" href="{{ url "/logout" }}"><span class="icon-logout"></span> Logout</a>
<a class="username settings" href="{{ .Header.MemberSettingsUrl }}"><span class="icon-settings"></span> {{ .User.Username }}</a>
<a class="logout" href="{{ .Header.LogoutActionUrl }}"><span class="icon-logout"></span> Logout</a>
{{ else }}
<a class="register" id="register-link" href="{{ url "/member_register" }}">Register</a>
<a class="login" id="login-link" href="{{ projecturl "/login" .Project }}">Log in</a>
<a class="register" id="register-link" href="{{ .Header.RegisterUrl }}">Register</a>
<a class="login" id="login-link" href="{{ .LoginPageUrl }}">Log in</a>
<div id="login-popup">
<form action="{{ projecturl "/login" .Project }}" method="post">
<form action="{{ .Header.LoginActionUrl }}" method="post">
{{/* TODO: CSRF */}}
<table>
<tr>
@ -33,41 +33,41 @@
</div>
<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">
<a href="{{ url "/" }}" class="logo hmdev-logo">
<a href="{{ .Header.HMNHomepageUrl }}" class="logo hmdev-logo">
<div class="underscore"></div>
</a>
<div class="items flex items-center justify-center justify-start-ns">
{{ if not .Project.IsHMN }}
<a class="project-logo" href="{{ projecturl "/" .Project }}">
<a class="project-logo" href="{{ .Header.ProjectHomepageUrl }}">
<h1>{{ .Project.Name }}</h1>
</a>
{{ end }}
{{ if .Project.HasBlog }}
<a href="{{ projecturl "/blog" .Project }}" class="blog">Blog</a>
<a href="{{ .Header.BlogUrl }}" class="blog">Blog</a>
{{ end }}
{{ if .Project.HasForum }}
<a href="{{ projecturl "/forums" .Project }}" class="forums">Forums</a>
<a href="{{ .Header.ForumsUrl }}" class="forums">Forums</a>
{{ end }}
{{ if .Project.HasWiki }}
<a href="{{ projecturl "/wiki" .Project }}" class="wiki">Wiki</a>
<a href="{{ .Header.WikiUrl }}" class="wiki">Wiki</a>
{{ end }}
{{ if .Project.HasLibrary }}
<a href="{{ projecturl "/library" .Project }}" class="library">Library</a>
<a href="{{ .Header.LibraryUrl }}" class="library">Library</a>
{{ end }}
{{ if .Project.IsHMN }}
<a href="{{ projecturl "/manifesto" .Project }}" class="misson">Mission</a>
<a href="{{ .Header.ManifestoUrl }}" class="misson">Mission</a>
{{ end }}
{{/* {% if project.default_annotation_category %} */}}
{{ 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 }}
{{/* {% if showEditLink == True %} */}}
{{/* {{ 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 }} */}}
</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? */}}
<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"/>

View File

@ -35,25 +35,25 @@
<h2 class="di-l mr2-l">Around the Network</h2>
<ul class="list dib-l">
<li class="dib-ns ma0 ph2">
<a href="{{ url "/feed" }}">View all posts on HMN</a>
<a href="{{ .FeedUrl }}">View all posts on HMN</a>
</li>
<li class="dib-ns ma0 ph2">
<a href="{{ url "/podcast" }}">Podcast</a>
<a href="{{ .PodcastUrl }}">Podcast</a>
</li>
{{/* TODO: Make a better IRC intro page because the current one is trash anyway */}}
{{/*
<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 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 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 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>
</ul>
</div>
@ -78,7 +78,7 @@
{{ $c1 := hex2color $proj.Color1 }}
<a
class="project-title"
href="{{ projecturl "/" $proj }}"
href="{{ $proj.Url }}"
>
<h2 class="ph3">{{ $proj.Name }}</h2>
</a>
@ -92,7 +92,7 @@
{{ end }}
<div class="ph3 thread unread more">
<a class="title" href="{{ projecturl "/forums" $proj }}">
<a class="title" href="{{ $entry.ForumsUrl }}">
More posts &rarr;
</a>
</div>
@ -113,7 +113,7 @@
<h2 class="di-l mr2-l">Community Showcase</h2>
<ul class="list dib-l">
<li class="dib-ns ma0 ph2">
<a href="{% url 'showcase' %}">View all</a>
<a href="{{ .ShowcaseUrl }}">View all</a>
</li>
</ul>
</div>
@ -129,7 +129,7 @@
</div>
<div class="c--dimmer i pv2 ph3 ph0-ns">
This is a selection of recent work done by community members. Want to participate? <a href="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>
<script>

View File

@ -40,7 +40,7 @@
</style>
{{ block "extrahead" . }}{{ end }}
<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="60x60" href="{{ static "apple-icon-60x60.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
Breadcrumbs []Breadcrumb
LoginPageUrl string
ProjectCSSUrl string
Project Project
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 {
@ -54,6 +90,7 @@ type Project struct {
Subdomain string
Color1 string
Color2 string
Url string
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 {
templates.BaseData
Posts []templates.PostListItem
Pagination templates.Pagination
Posts []templates.PostListItem
Pagination templates.Pagination
AtomFeedUrl string
MarkAllReadUrl string
}
func Feed(c *RequestContext) ResponseData {
@ -76,13 +78,14 @@ func Feed(c *RequestContext) ResponseData {
c.Perf.StartBlock("SQL", "Fetch posts")
type feedPostQuery struct {
Post models.Post `db:"post"`
Thread models.Thread `db:"thread"`
Cat models.Category `db:"cat"`
Proj models.Project `db:"proj"`
User models.User `db:"auth_user"`
ThreadLastReadTime *time.Time `db:"tlri.lastread"`
CatLastReadTime *time.Time `db:"clri.lastread"`
Post models.Post `db:"post"`
Thread models.Thread `db:"thread"`
Cat models.Category `db:"cat"`
Proj models.Project `db:"proj"`
LibraryResource *models.LibraryResource `db:"lib_resource"`
User models.User `db:"auth_user"`
ThreadLastReadTime *time.Time `db:"tlri.lastread"`
CatLastReadTime *time.Time `db:"clri.lastread"`
}
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_category AS cat ON cat.id = post.category_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
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
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
post.category_kind = ANY ($2)
AND post.deleted = FALSE
@ -123,22 +127,6 @@ func Feed(c *RequestContext) ResponseData {
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
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")
var postItems []templates.PostListItem
for _, iPostResult := range posts.ToSlice() {
@ -151,42 +139,16 @@ func Feed(c *RequestContext) ResponseData {
hasRead = true
}
breadcrumbs := make([]templates.Breadcrumb, 0, len(lineageBuilder.GetLineage(postResult.Cat.ID)))
breadcrumbs = append(breadcrumbs, templates.Breadcrumb{
Name: postResult.Proj.Name,
Url: hmnurl.ProjectUrl("/", nil, postResult.Proj.Slug),
})
if postResult.Post.CategoryKind == models.CatKindLibraryResource {
// TODO(asaf): Fetch library root topic for the project and construct breadcrumb for it
} else {
lineage := lineageBuilder.GetLineage(postResult.Cat.ID)
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,
})
postItems = append(postItems, MakePostListItem(
lineageBuilder,
&postResult.Proj,
&postResult.Thread,
&postResult.Post,
&postResult.User,
postResult.LibraryResource,
!hasRead,
true,
))
}
c.Perf.EndBlock()
@ -197,8 +159,10 @@ func Feed(c *RequestContext) ResponseData {
res.WriteTemplate("feed.html", FeedData{
BaseData: baseData,
Posts: postItems,
Pagination: pagination,
AtomFeedUrl: hmnurl.BuildAtomFeed(),
MarkAllReadUrl: hmnurl.BuildMarkRead(0),
Posts: postItems,
Pagination: pagination,
}, c.Perf)
return res

View File

@ -1,9 +1,7 @@
package website
import (
"context"
"errors"
"fmt"
"math"
"net/http"
"strconv"
@ -16,13 +14,13 @@ import (
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/utils"
"github.com/jackc/pgx/v4/pgxpool"
)
type forumCategoryData struct {
templates.BaseData
CategoryUrl string
NewThreadUrl string
MarkReadUrl string
Threads []templates.ThreadListItem
Pagination templates.Pagination
Subcategories []forumSubcategoryData
@ -38,19 +36,17 @@ type forumSubcategoryData struct {
func ForumCategory(c *RequestContext) ResponseData {
const threadsPerPage = 25
// TODO(asaf): Consider making this more robust.
// Right now this code allows for weird urls like:
// "/forums/asdf/wip" which doesn't verify the lineage and displays the wip forums
// "/forums/wip///" which fetches the main forums page because it happens to have a blank slug
// "/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
// "/forums/wip/1/" 404 - doesn't match the regex
// "/forums/" 404 - doesn't match the regex
catPath := c.PathParams["cats"]
catSlugs := strings.Split(catPath, "/")
currentCatId := fetchCatIdFromSlugs(c.Context(), c.Conn, catSlugs, c.CurrentProject.ID)
// TODO(asaf): 404 if we can't find our cat.
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)
c.Perf.EndBlock()
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
if !valid {
return FourOhFour(c)
}
currentSubforumSlugs := lineageBuilder.GetSubforumLineageSlugs(currentCatId)
c.Perf.StartBlock("SQL", "Fetch count of page threads")
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 {
page = pageParsed
} 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 {
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
@ -143,10 +139,8 @@ func ForumCategory(c *RequestContext) ResponseData {
}
return templates.ThreadListItem{
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: ThreadUrl(row.Thread, models.CatKindForum, categoryUrls[currentCatId]),
Title: row.Thread.Title,
Url: hmnurl.BuildForumThread(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(row.Thread.CategoryID), row.Thread.ID, row.Thread.Title, 1),
FirstUser: templates.UserToTemplate(row.FirstUser),
FirstDate: row.FirstPost.PostDate,
LastUser: templates.UserToTemplate(row.LastUser),
@ -168,29 +162,9 @@ func ForumCategory(c *RequestContext) ResponseData {
var subcats []forumSubcategoryData
if page == 1 {
c.Perf.StartBlock("SQL", "Fetch subcategories")
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)
subcatNodes := categoryTree[currentCatId].Children
for _, catNode := range subcatNodes {
c.Perf.StartBlock("SQL", "Fetch count of subcategory threads")
// TODO(asaf): [PERF] [MINOR] Consider replacing querying count per subcat with a single query for all cats with GROUP BY.
numThreads, err := db.QueryInt(c.Context(), c.Conn,
@ -201,7 +175,7 @@ func ForumCategory(c *RequestContext) ResponseData {
thread.category_id = $1
AND NOT thread.deleted
`,
catRow.Cat.ID,
catNode.ID,
)
if err != nil {
panic(oops.New(err, "failed to get count of threads"))
@ -233,7 +207,7 @@ func ForumCategory(c *RequestContext) ResponseData {
ORDER BY lastpost.postdate DESC
LIMIT 3
`,
catRow.Cat.ID,
catNode.ID,
currentUserId,
)
if err != nil {
@ -249,8 +223,8 @@ func ForumCategory(c *RequestContext) ResponseData {
}
subcats = append(subcats, forumSubcategoryData{
Name: *catRow.Cat.Name,
Url: categoryUrls[catRow.Cat.ID],
Name: *catNode.Name,
Url: hmnurl.BuildForumCategory(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(catNode.ID), 1),
Threads: threads,
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.
{
Name: c.CurrentProject.Name,
Url: hmnurl.ProjectUrl("/", nil, c.CurrentProject.Slug),
Url: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
},
{
Name: "Forums",
Url: categoryUrls[currentCatId],
Url: hmnurl.BuildForumCategory(c.CurrentProject.Slug, nil, 1),
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
err = res.WriteTemplate("forum_category.html", forumCategoryData{
BaseData: baseData,
CategoryUrl: categoryUrls[currentCatId],
Threads: threads,
BaseData: baseData,
NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs),
MarkReadUrl: hmnurl.BuildMarkRead(currentCatId),
Threads: threads,
Pagination: templates.Pagination{
Current: page,
Total: numPages,
FirstUrl: categoryUrls[currentCatId],
LastUrl: fmt.Sprintf("%s/%d", categoryUrls[currentCatId], numPages),
NextUrl: fmt.Sprintf("%s/%d", categoryUrls[currentCatId], page+1),
PreviousUrl: fmt.Sprintf("%s/%d", categoryUrls[currentCatId], page-1),
FirstUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, 1),
LastUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, numPages),
NextUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page+1, numPages)),
PreviousUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page-1, numPages)),
},
Subcategories: subcats,
}, c.Perf)
@ -312,11 +295,23 @@ type forumThreadData struct {
func ForumThread(c *RequestContext) ResponseData {
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"])
if err != nil {
return FourOhFour(c)
}
currentSubforumSlugs := lineageBuilder.GetSubforumLineageSlugs(currentCatId)
c.Perf.StartBlock("SQL", "Fetch current thread")
type threadQueryResult struct {
Thread models.Thread `db:"thread"`
@ -330,10 +325,10 @@ func ForumThread(c *RequestContext) ResponseData {
WHERE
thread.id = $1
AND NOT thread.deleted
AND cat.project_id = $2
AND cat.id = $2
`,
threadId,
c.CurrentProject.ID,
currentCatId, // NOTE(asaf): This verifies that the requested thread is under the requested subforum.
)
c.Perf.EndBlock()
if err != nil {
@ -345,14 +340,6 @@ func ForumThread(c *RequestContext) ResponseData {
}
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,
`
SELECT COUNT(*)
@ -368,17 +355,17 @@ func ForumThread(c *RequestContext) ResponseData {
}
page, numPages, ok := getPageInfo(c.PathParams["page"], numPosts, postsPerPage)
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)
}
pagination := templates.Pagination{
Current: page,
Total: numPages,
FirstUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, subforums, thread.ID, 1),
LastUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, subforums, thread.ID, numPages),
NextUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, subforums, thread.ID, utils.IntClamp(1, page+1, numPages)),
PreviousUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, subforums, thread.ID, utils.IntClamp(1, page-1, numPages)),
FirstUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, 1),
LastUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, numPages),
NextUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, 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")
@ -418,7 +405,7 @@ func ForumThread(c *RequestContext) ResponseData {
post := templates.PostToTemplate(&row.Post, row.Author)
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)
}
@ -432,8 +419,8 @@ func ForumThread(c *RequestContext) ResponseData {
BaseData: baseData,
Thread: templates.ThreadToTemplate(&thread),
Posts: posts,
CategoryUrl: categoryUrls[thread.CategoryID],
ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, subforums, thread.ID, *thread.FirstID),
CategoryUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, 1),
ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, *thread.FirstID),
Pagination: pagination,
}, c.Perf)
if err != nil {
@ -443,46 +430,36 @@ func ForumThread(c *RequestContext) ResponseData {
return res
}
func fetchCatIdFromSlugs(ctx context.Context, conn *pgxpool.Pool, catSlugs []string, projectId int) int {
if len(catSlugs) == 1 {
var err error
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
func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, catPath string) (int, bool) {
if project.ForumID == nil {
return -1, false
}
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"
"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/oops"
"git.handmade.network/hmn/hmn/src/templates"
@ -18,12 +19,21 @@ type LandingTemplateData struct {
NewsPost LandingPageFeaturedPost
PostColumns [][]LandingPageProject
ShowcaseTimelineJson string
FeedUrl string
PodcastUrl string
StreamsUrl string
IRCUrl string
DiscordUrl string
ShowUrl string
ShowcaseUrl string
}
type LandingPageProject struct {
Project templates.Project
FeaturedPost *LandingPageFeaturedPost
Posts []templates.PostListItem
ForumsUrl string
}
type LandingPageFeaturedPost struct {
@ -45,11 +55,12 @@ func Index(c *RequestContext) ResponseData {
SELECT $columns
FROM handmade_project
WHERE
flags = 0
OR id = $1
(flags = 0 AND NOT lifecycle = ANY($1))
OR id = $2
ORDER BY all_last_updated DESC
LIMIT $2
LIMIT $3
`,
[]models.ProjectLifecycle{models.ProjectLifecycleUnapproved, models.ProjectLifecycleApprovalRequired},
models.HMNProjectID,
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()
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
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))
type projectPostQuery struct {
Post models.Post `db:"post"`
Thread models.Thread `db:"thread"`
User models.User `db:"auth_user"`
ThreadLastReadTime *time.Time `db:"tlri.lastread"`
CatLastReadTime *time.Time `db:"clri.lastread"`
Post models.Post `db:"post"`
Thread models.Thread `db:"thread"`
User models.User `db:"auth_user"`
LibraryResource *models.LibraryResource `db:"lib_resource"`
ThreadLastReadTime *time.Time `db:"tlri.lastread"`
CatLastReadTime *time.Time `db:"clri.lastread"`
}
projectPostIter, err := db.Query(c.Context(), c.Conn, projectPostQuery{},
`
@ -98,6 +112,7 @@ func Index(c *RequestContext) ResponseData {
AND clri.user_id = $1
)
LEFT JOIN auth_user ON post.author_id = auth_user.id
LEFT JOIN handmade_libraryresource as lib_resource ON lib_resource.category_id = post.category_id
WHERE
post.project_id = $2
AND post.category_kind IN ($3, $4, $5, $6)
@ -117,8 +132,16 @@ func Index(c *RequestContext) ResponseData {
}
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{
Project: templates.ProjectToTemplate(proj),
Project: templates.ProjectToTemplate(proj),
ForumsUrl: forumsUrl,
}
for _, projectPostRow := range projectPosts {
@ -150,28 +173,35 @@ func Index(c *RequestContext) ResponseData {
WHERE
post.id = $1
`, projectPost.Post.ID)
if err != nil {
panic(err)
}
c.Perf.EndBlock()
if err != nil {
c.Logger.Error().Err(err).Msg("failed to fetch featured post content")
continue
}
content := contentResult.(*featuredContentResult).Content
landingPageProject.FeaturedPost = &LandingPageFeaturedPost{
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),
Date: projectPost.Post.PostDate,
Unread: !hasRead,
Content: template.HTML(content),
}
} else {
landingPageProject.Posts = append(landingPageProject.Posts, templates.PostListItem{
Title: projectPost.Thread.Title,
Url: PostUrl(projectPost.Post, projectPost.Post.CategoryKind, categoryUrls[projectPost.Post.CategoryID]),
User: templates.UserToTemplate(&projectPost.User),
Date: projectPost.Post.PostDate,
Unread: !hasRead,
})
landingPageProject.Posts = append(
landingPageProject.Posts,
MakePostListItem(
lineageBuilder,
proj,
&projectPost.Thread,
&projectPost.Post,
&projectPost.User,
projectPost.LibraryResource,
!hasRead,
false,
),
)
}
}
@ -185,30 +215,6 @@ func Index(c *RequestContext) ResponseData {
}
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.
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 {
Post models.Post `db:"post"`
PostVersion models.PostVersion `db:"ver"`
@ -251,12 +258,11 @@ func Index(c *RequestContext) ResponseData {
FROM
handmade_post AS post
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 handmade_postversion AS ver ON post.current_id = ver.id
WHERE
cat.project_id = $1
AND cat.kind = $2
post.project_id = $1
AND post.category_kind = $2
AND post.id = thread.first_id
AND NOT thread.deleted
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"))
}
newsPostResult := newsPostRow.(*newsPostQuery)
c.Perf.EndBlock()
baseData := getBaseData(c)
baseData.BodyClasses = append(baseData.BodyClasses, "hmdev", "landing") // TODO: Is "hmdev" necessary any more?
var res ResponseData
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{
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),
Date: newsPostResult.Post.PostDate,
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.
routes.POST(hmnurl.RegexLogin, Login)
routes.GET(hmnurl.RegexLogout, Logout)
routes.POST(hmnurl.RegexLoginAction, Login)
routes.GET(hmnurl.RegexLogoutAction, Logout)
routes.StdHandler(hmnurl.RegexPublic,
http.StripPrefix("/public/", http.FileServer(http.Dir("public"))),
)
@ -130,9 +130,39 @@ func getBaseData(c *RequestContext) templates.BaseData {
}
return templates.BaseData{
Project: templates.ProjectToTemplate(c.CurrentProject),
User: templateUser,
Theme: "light",
Project: templates.ProjectToTemplate(c.CurrentProject),
LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()),
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()
log := logging.Info()
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]) {
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)
}
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) {
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
import (
"context"
"fmt"
"strings"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
"github.com/jackc/pgx/v4/pgxpool"
)
type categoryUrlQueryResult struct {
Cat models.Category `db:"cat"`
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.
func BuildProjectMainCategoryUrl(projectSlug string, kind models.CategoryKind) string {
switch kind {
case models.CatKindBlog:
return fmt.Sprintf("%s/p/%d/e/%d", categoryUrl, post.ThreadID, post.ID)
return hmnurl.BuildBlog(projectSlug, 1)
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 ""
}
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 ""
return hmnurl.BuildProjectHomepage(projectSlug)
}