Start forum category index; fix reflection bugs
This commit is contained in:
parent
285fd3eaf0
commit
5f763d334c
105
src/db/db.go
105
src/db/db.go
|
@ -3,10 +3,12 @@ package db
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"github.com/jackc/pgtype"
|
||||
"github.com/jackc/pgx/v4"
|
||||
|
@ -15,6 +17,40 @@ import (
|
|||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
/*
|
||||
Values of these kinds are ok to query even if they are not directly understood by pgtype.
|
||||
This is common for custom types like:
|
||||
|
||||
type CategoryKind int
|
||||
*/
|
||||
var queryableKinds = []reflect.Kind{
|
||||
reflect.Int,
|
||||
}
|
||||
|
||||
/*
|
||||
Checks if we are able to handle a particular type in a database query. This applies only to
|
||||
primitive types and not structs, since the database only returns individual primitive types
|
||||
and it is our job to stitch them back together into structs later.
|
||||
*/
|
||||
func typeIsQueryable(t reflect.Type) bool {
|
||||
_, isRecognizedByPgtype := connInfo.DataTypeForValue(reflect.New(t).Elem().Interface()) // if pgtype recognizes it, we don't need to dig in further for more `db` tags
|
||||
// NOTE: boy it would be nice if we didn't have to do reflect.New here, considering that pgtype is just doing reflection on the value anyway
|
||||
|
||||
if isRecognizedByPgtype {
|
||||
return true
|
||||
}
|
||||
|
||||
// pgtype doesn't recognize it, but maybe it's a primitive type we can deal with
|
||||
k := t.Kind()
|
||||
for _, qk := range queryableKinds {
|
||||
if k == qk {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
var connInfo = pgtype.NewConnInfo()
|
||||
|
||||
func NewConn() *pgx.Conn {
|
||||
|
@ -61,12 +97,35 @@ func (it *StructQueryIterator) Next() (interface{}, bool) {
|
|||
panic(err)
|
||||
}
|
||||
|
||||
// Better logging of panics in this confusing reflection process
|
||||
var currentField reflect.StructField
|
||||
var currentValue reflect.Value
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if currentValue.IsValid() {
|
||||
logging.Error().
|
||||
Str("field name", currentField.Name).
|
||||
Stringer("field type", currentField.Type).
|
||||
Interface("value", currentValue.Interface()).
|
||||
Stringer("value type", currentValue.Type()).
|
||||
Msg("panic in iterator")
|
||||
}
|
||||
|
||||
if currentField.Name != "" {
|
||||
panic(fmt.Errorf("panic while processing field '%s': %v", currentField.Name, r))
|
||||
} else {
|
||||
panic(r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for i, val := range vals {
|
||||
if val == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
field := followPathThroughStructs(result, it.fieldPaths[i])
|
||||
var field reflect.Value
|
||||
field, currentField = followPathThroughStructs(result, it.fieldPaths[i])
|
||||
if field.Kind() == reflect.Ptr {
|
||||
field.Set(reflect.New(field.Type().Elem()))
|
||||
field = field.Elem()
|
||||
|
@ -78,6 +137,7 @@ func (it *StructQueryIterator) Next() (interface{}, bool) {
|
|||
if valReflected.Kind() == reflect.Ptr {
|
||||
valReflected = valReflected.Elem()
|
||||
}
|
||||
currentValue = valReflected
|
||||
|
||||
switch field.Kind() {
|
||||
case reflect.Int:
|
||||
|
@ -85,6 +145,9 @@ func (it *StructQueryIterator) Next() (interface{}, bool) {
|
|||
default:
|
||||
field.Set(valReflected)
|
||||
}
|
||||
|
||||
currentField = reflect.StructField{}
|
||||
currentValue = reflect.Value{}
|
||||
}
|
||||
|
||||
return result.Interface(), true
|
||||
|
@ -111,22 +174,35 @@ func (it *StructQueryIterator) ToSlice() []interface{} {
|
|||
return result
|
||||
}
|
||||
|
||||
func followPathThroughStructs(structVal reflect.Value, path []int) reflect.Value {
|
||||
func followPathThroughStructs(structPtrVal reflect.Value, path []int) (reflect.Value, reflect.StructField) {
|
||||
if len(path) < 1 {
|
||||
panic("can't follow an empty path")
|
||||
panic(oops.New(nil, "can't follow an empty path"))
|
||||
}
|
||||
|
||||
val := structVal
|
||||
if structPtrVal.Kind() != reflect.Ptr || structPtrVal.Elem().Kind() != reflect.Struct {
|
||||
panic(oops.New(nil, "structPtrVal must be a pointer to a struct; got value of type %s", structPtrVal.Type()))
|
||||
}
|
||||
|
||||
// more informative panic recovery
|
||||
var field reflect.StructField
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
panic(oops.New(nil, "panic at field '%s': %v", field.Name, r))
|
||||
}
|
||||
}()
|
||||
|
||||
val := structPtrVal
|
||||
for _, i := range path {
|
||||
if val.Kind() == reflect.Ptr && val.Type().Elem().Kind() == reflect.Struct {
|
||||
if val.IsNil() {
|
||||
val.Set(reflect.New(val.Type()))
|
||||
val.Set(reflect.New(val.Type().Elem()))
|
||||
}
|
||||
val = val.Elem()
|
||||
}
|
||||
field = val.Type().Field(i)
|
||||
val = val.Field(i)
|
||||
}
|
||||
return val
|
||||
return val, field
|
||||
}
|
||||
|
||||
func Query(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (*StructQueryIterator, error) {
|
||||
|
@ -154,7 +230,7 @@ func Query(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, que
|
|||
}, nil
|
||||
}
|
||||
|
||||
func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix string) ([]string, [][]int, error) {
|
||||
func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix string) (names []string, paths [][]int, err error) {
|
||||
var columnNames []string
|
||||
var fieldPaths [][]int
|
||||
|
||||
|
@ -172,14 +248,14 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
|
|||
|
||||
if columnName := field.Tag.Get("db"); columnName != "" {
|
||||
fieldType := field.Type
|
||||
if destType.Kind() == reflect.Ptr {
|
||||
fieldType = destType.Elem()
|
||||
if fieldType.Kind() == reflect.Ptr {
|
||||
fieldType = fieldType.Elem()
|
||||
}
|
||||
|
||||
_, isRecognizedByPgtype := connInfo.DataTypeForValue(reflect.New(fieldType).Elem().Interface()) // if pgtype recognizes it, we don't need to dig in further for more `db` tags
|
||||
// NOTE: boy it would be nice if we didn't have to do reflect.New here, considering that pgtype is just doing reflection on the value anyway
|
||||
|
||||
if fieldType.Kind() == reflect.Struct && !isRecognizedByPgtype {
|
||||
if typeIsQueryable(fieldType) {
|
||||
columnNames = append(columnNames, prefix+columnName)
|
||||
fieldPaths = append(fieldPaths, path)
|
||||
} else if fieldType.Kind() == reflect.Struct {
|
||||
subCols, subPaths, err := getColumnNamesAndPaths(fieldType, path, columnName+".")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
@ -187,8 +263,7 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
|
|||
columnNames = append(columnNames, subCols...)
|
||||
fieldPaths = append(fieldPaths, subPaths...)
|
||||
} else {
|
||||
columnNames = append(columnNames, prefix+columnName)
|
||||
fieldPaths = append(fieldPaths, path)
|
||||
return nil, nil, oops.New(nil, "field '%s' in type %s has invalid type '%s'", field.Name, destType, field.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPaths(t *testing.T) {
|
||||
type CustomInt int
|
||||
type S struct {
|
||||
I int `db:"I"`
|
||||
PI *int `db:"PI"`
|
||||
CI CustomInt `db:"CI"`
|
||||
PCI *CustomInt `db:"PCI"`
|
||||
B bool `db:"B"`
|
||||
PB *bool `db:"PB"`
|
||||
|
||||
NoTag int
|
||||
}
|
||||
type Nested struct {
|
||||
S S `db:"S"`
|
||||
PS *S `db:"PS"`
|
||||
|
||||
NoTag S
|
||||
}
|
||||
|
||||
names, paths, err := getColumnNamesAndPaths(reflect.TypeOf(Nested{}), nil, "")
|
||||
if assert.Nil(t, err) {
|
||||
assert.Equal(t, []string{
|
||||
"S.I", "S.PI",
|
||||
"S.CI", "S.PCI",
|
||||
"S.B", "S.PB",
|
||||
"PS.I", "PS.PI",
|
||||
"PS.CI", "PS.PCI",
|
||||
"PS.B", "PS.PB",
|
||||
}, names)
|
||||
assert.Equal(t, [][]int{
|
||||
{0, 0}, {0, 1}, {0, 2}, {0, 3}, {0, 4}, {0, 5},
|
||||
{1, 0}, {1, 1}, {1, 2}, {1, 3}, {1, 4}, {1, 5},
|
||||
}, paths)
|
||||
assert.True(t, len(names) == len(paths))
|
||||
}
|
||||
|
||||
testStruct := Nested{}
|
||||
for i, path := range paths {
|
||||
val, field := followPathThroughStructs(reflect.ValueOf(&testStruct), path)
|
||||
assert.True(t, val.IsValid())
|
||||
assert.True(t, strings.Contains(names[i], field.Name))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
{{ template "base.html" . }}
|
||||
|
||||
{{ define "content" }}
|
||||
<div class="content-block">
|
||||
{{ range .Threads }}
|
||||
{{ template "thread_list_item.html" . }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
|
@ -1,5 +1,5 @@
|
|||
{{/*
|
||||
This template is intended to display a single post or thread in the context of a forum, the feed, or a similar layout.
|
||||
This template is intended to display a single post in the context of a forum, the feed, or a similar layout.
|
||||
|
||||
It should be called with PostListItem.
|
||||
*/}}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
{{/*
|
||||
This template is intended to display a single thread in the context of a forum, the feed, or a similar layout.
|
||||
|
||||
It should be called with ThreadListItem.
|
||||
*/}}
|
||||
|
||||
<div class="post-list-item flex items-center ph3 pv2 {{ if .Unread }}unread{{ else }}read{{ end }} {{ .Classes }}">
|
||||
<img class="avatar-icon mr2" src="{{ .FirstUser.AvatarUrl }}">
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="breadcrumbs">
|
||||
{{ range $i, $breadcrumb := .Breadcrumbs }}
|
||||
{{ if gt $i 0 }} » {{ end }}
|
||||
<a href="{{ $breadcrumb.Url }}">{{ $breadcrumb.Name }}</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="title nowrap truncate"><a href="{{ .Url }}">{{ .Title }}</a></div>
|
||||
<div class="details">
|
||||
<a class="user" href="{{ .FirstUser.ProfileUrl }}">{{ .FirstUser.Name }}</a> — <span class="datetime">{{ relativedate .FirstDate }}</span>
|
||||
</div>
|
||||
{{ with .Content }}
|
||||
<div class="mt2">
|
||||
{{ . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="latestpost dn flex-ns flex-shrink-0 items-center w5 ml2">
|
||||
<img class="avatar-icon mr2" src="{{ .LastUser.AvatarUrl }}">
|
||||
<div>
|
||||
<div>Last post <span class="datetime">{{ relativedate .LastDate }}</span></div>
|
||||
<a class="user" href="{{ .LastUser.ProfileUrl }}">{{ .LastUser.Name }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="goto">
|
||||
<a href="{{ .Url }}">»</a>
|
||||
</div>
|
||||
</div>
|
|
@ -83,11 +83,29 @@ type PostListItem struct {
|
|||
Title string
|
||||
Url string
|
||||
Breadcrumbs []Breadcrumb
|
||||
User User
|
||||
Date time.Time
|
||||
Unread bool
|
||||
Classes string
|
||||
Content string
|
||||
|
||||
User User
|
||||
Date time.Time
|
||||
|
||||
Unread bool
|
||||
Classes string
|
||||
Content string
|
||||
}
|
||||
|
||||
// Data from thread_list_item.html
|
||||
type ThreadListItem struct {
|
||||
Title string
|
||||
Url string
|
||||
Breadcrumbs []Breadcrumb
|
||||
|
||||
FirstUser User
|
||||
FirstDate time.Time
|
||||
LastUser User
|
||||
LastDate time.Time
|
||||
|
||||
Unread bool
|
||||
Classes string
|
||||
Content string
|
||||
}
|
||||
|
||||
type Breadcrumb struct {
|
||||
|
|
|
@ -1,46 +1,35 @@
|
|||
package website
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/templates"
|
||||
|
||||
"github.com/jackc/pgx/v4/pgxpool"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
)
|
||||
|
||||
type forumCategoryData struct {
|
||||
templates.BaseData
|
||||
|
||||
Threads []templates.ThreadListItem
|
||||
}
|
||||
|
||||
func ForumCategory(c *RequestContext) ResponseData {
|
||||
const threadsPerPage = 25
|
||||
|
||||
catPath := c.PathParams["cats"]
|
||||
catSlugs := strings.Split(catPath, "/")
|
||||
|
||||
catSlug := catSlugs[len(catSlugs)-1]
|
||||
if len(catSlugs) == 1 {
|
||||
catSlug = ""
|
||||
}
|
||||
|
||||
// TODO: Is this query right? Do we need to do a better special case for when it's the root category?
|
||||
currentCatId, err := db.QueryInt(c.Context(), c.Conn,
|
||||
`
|
||||
SELECT id
|
||||
FROM handmade_category
|
||||
WHERE
|
||||
slug = $1
|
||||
AND kind = $2
|
||||
AND project_id = $3
|
||||
`,
|
||||
catSlug,
|
||||
models.CatKindForum,
|
||||
c.CurrentProject.ID,
|
||||
)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to get current category id"))
|
||||
}
|
||||
currentCatId := fetchCatIdFromSlugs(c.Context(), c.Conn, catSlugs, c.CurrentProject.ID)
|
||||
categoryUrls := GetProjectCategoryUrls(c.Context(), c.Conn, c.CurrentProject.ID)
|
||||
|
||||
numThreads, err := db.QueryInt(c.Context(), c.Conn,
|
||||
`
|
||||
|
@ -82,6 +71,8 @@ func ForumCategory(c *RequestContext) ResponseData {
|
|||
Thread models.Thread `db:"thread"`
|
||||
FirstPost models.Post `db:"firstpost"`
|
||||
LastPost models.Post `db:"lastpost"`
|
||||
FirstUser *models.User `db:"firstuser"`
|
||||
LastUser *models.User `db:"lastuser"`
|
||||
ThreadLastReadTime *time.Time `db:"tlri.lastread"`
|
||||
CatLastReadTime *time.Time `db:"clri.lastread"`
|
||||
}
|
||||
|
@ -92,15 +83,16 @@ func ForumCategory(c *RequestContext) ResponseData {
|
|||
handmade_thread AS thread
|
||||
JOIN handmade_post AS firstpost ON thread.first_id = firstpost.id
|
||||
JOIN handmade_post AS lastpost ON thread.last_id = lastpost.id
|
||||
LEFT OUTER JOIN handmade_threadlastreadinfo AS tlri ON (
|
||||
LEFT JOIN auth_user AS firstuser ON firstpost.author_id = firstuser.id
|
||||
LEFT JOIN auth_user AS lastuser ON lastpost.author_id = lastuser.id
|
||||
LEFT JOIN handmade_threadlastreadinfo AS tlri ON (
|
||||
tlri.thread_id = thread.id
|
||||
AND tlri.user_id = $2
|
||||
)
|
||||
LEFT OUTER JOIN handmade_categorylastreadinfo AS clri ON (
|
||||
LEFT JOIN handmade_categorylastreadinfo AS clri ON (
|
||||
clri.category_id = $1
|
||||
AND clri.user_id = $2
|
||||
)
|
||||
-- LEFT OUTER JOIN auth_user ON post.author_id = auth_user.id
|
||||
WHERE
|
||||
thread.category_id = $1
|
||||
AND NOT thread.deleted
|
||||
|
@ -115,50 +107,112 @@ func ForumCategory(c *RequestContext) ResponseData {
|
|||
if err != nil {
|
||||
panic(oops.New(err, "failed to fetch threads"))
|
||||
}
|
||||
defer itMainThreads.Close()
|
||||
|
||||
var res ResponseData
|
||||
|
||||
var threads []templates.ThreadListItem
|
||||
for _, irow := range itMainThreads.ToSlice() {
|
||||
row := irow.(*mainPostsQueryResult)
|
||||
res.Write([]byte(fmt.Sprintf("%s\n", row.Thread.Title)))
|
||||
|
||||
threads = append(threads, templates.ThreadListItem{
|
||||
Title: row.Thread.Title,
|
||||
Url: ThreadUrl(row.Thread, models.CatKindForum, categoryUrls[currentCatId]),
|
||||
|
||||
FirstUser: templates.UserToTemplate(row.FirstUser),
|
||||
FirstDate: row.FirstPost.PostDate,
|
||||
LastUser: templates.UserToTemplate(row.LastUser),
|
||||
LastDate: row.LastPost.PostDate,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------
|
||||
// Subcategory things
|
||||
// ---------------------
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetch subcategories")
|
||||
type queryResult struct {
|
||||
Cat models.Category `db:"cat"`
|
||||
}
|
||||
itSubcats, err := db.Query(c.Context(), c.Conn, queryResult{},
|
||||
`
|
||||
WITH current AS (
|
||||
//c.Perf.StartBlock("SQL", "Fetch subcategories")
|
||||
//type queryResult struct {
|
||||
// Cat models.Category `db:"cat"`
|
||||
//}
|
||||
//itSubcats, err := db.Query(c.Context(), c.Conn, queryResult{},
|
||||
// `
|
||||
// WITH current AS (
|
||||
// SELECT id
|
||||
// FROM handmade_category
|
||||
// WHERE
|
||||
// slug = $1
|
||||
// AND kind = $2
|
||||
// AND project_id = $3
|
||||
// )
|
||||
// SELECT $columns
|
||||
// FROM
|
||||
// handmade_category AS cat,
|
||||
// current
|
||||
// WHERE
|
||||
// cat.id = current.id
|
||||
// OR cat.parent_id = current.id
|
||||
// `,
|
||||
// catSlug,
|
||||
// models.CatKindForum,
|
||||
// c.CurrentProject.ID,
|
||||
//)
|
||||
//if err != nil {
|
||||
// panic(oops.New(err, "failed to fetch subcategories"))
|
||||
//}
|
||||
//c.Perf.EndBlock()
|
||||
|
||||
//_ = itSubcats // TODO: Actually query subcategory post data
|
||||
|
||||
baseData := getBaseData(c)
|
||||
baseData.Title = "yeet"
|
||||
|
||||
var res ResponseData
|
||||
res.WriteTemplate("forum_category.html", forumCategoryData{
|
||||
BaseData: baseData,
|
||||
Threads: threads,
|
||||
}, c.Perf)
|
||||
|
||||
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,
|
||||
)
|
||||
SELECT $columns
|
||||
FROM
|
||||
handmade_category AS cat,
|
||||
current
|
||||
WHERE
|
||||
cat.id = current.id
|
||||
OR cat.parent_id = current.id
|
||||
`,
|
||||
catSlug,
|
||||
models.CatKindForum,
|
||||
c.CurrentProject.ID,
|
||||
)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to fetch subcategories"))
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to get current category id"))
|
||||
}
|
||||
|
||||
return currentCatId
|
||||
}
|
||||
c.Perf.EndBlock()
|
||||
|
||||
_ = itSubcats // TODO: Actually query subcategory post data
|
||||
|
||||
return res
|
||||
}
|
||||
|
|
|
@ -103,8 +103,8 @@ func getBaseData(c *RequestContext) templates.BaseData {
|
|||
func FetchProjectBySlug(ctx context.Context, conn *pgxpool.Pool, slug string) (*models.Project, error) {
|
||||
subdomainProjectRow, err := db.QueryOne(ctx, conn, models.Project{}, "SELECT $columns FROM handmade_project WHERE slug = $1", slug)
|
||||
if err == nil {
|
||||
subdomainProject := subdomainProjectRow.(models.Project)
|
||||
return &subdomainProject, nil
|
||||
subdomainProject := subdomainProjectRow.(*models.Project)
|
||||
return subdomainProject, nil
|
||||
} else if !errors.Is(err, db.ErrNoMatchingRows) {
|
||||
return nil, oops.New(err, "failed to get projects by slug")
|
||||
}
|
||||
|
|
|
@ -124,3 +124,17 @@ func PostUrl(post models.Post, catKind models.CategoryKind, categoryUrl string)
|
|||
|
||||
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 ""
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue