Add tags to snippets on timelines

This commit is contained in:
Ben Visness 2021-11-11 11:00:46 -08:00
parent ffed86b33a
commit 4ea1338c32
16 changed files with 373 additions and 169 deletions

View File

@ -64,7 +64,12 @@ func (bot *botInstance) processShowcaseMsg(ctx context.Context, msg *Message, is
} }
if isJam { if isJam {
_, err := tx.Exec(ctx, `UPDATE handmade_snippet SET is_jam = TRUE WHERE id = $1`, snippet.ID) tagId, err := db.QueryInt(ctx, tx, `SELECT id FROM tags WHERE text = 'wheeljam'`)
if err != nil {
return oops.New(err, "failed to fetch id of jam tag")
}
_, err = tx.Exec(ctx, `INSERT INTO snippet_tags (snippet_id, tag_id) VALUES ($1, $2)`, snippet.ID, tagId)
if err != nil { if err != nil {
return oops.New(err, "failed to mark snippet as a jam snippet") return oops.New(err, "failed to mark snippet as a jam snippet")
} }

View File

@ -4,6 +4,8 @@ import (
"context" "context"
"time" "time"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/migration/types" "git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
@ -35,16 +37,31 @@ func (m PersonalProjects) Up(ctx context.Context, tx pgx.Tx) error {
id SERIAL NOT NULL PRIMARY KEY, id SERIAL NOT NULL PRIMARY KEY,
text VARCHAR(20) NOT NULL text VARCHAR(20) NOT NULL
); );
CREATE INDEX tags_by_text ON tags (text);
ALTER TABLE tags ALTER TABLE tags
ADD CONSTRAINT tag_syntax CHECK ( ADD CONSTRAINT tag_syntax CHECK (
text ~ '^([a-z0-9]+(-[a-z0-9]+)*)?$' text ~ '^([a-z0-9]+(-[a-z0-9]+)*)?$'
); );
CREATE INDEX tags_by_text ON tags (text); CREATE TABLE snippet_tags (
snippet_id INT NOT NULL REFERENCES handmade_snippet (id) ON DELETE CASCADE,
tag_id INT NOT NULL REFERENCES tags (id) ON DELETE CASCADE,
PRIMARY KEY (snippet_id, tag_id)
);
`) `)
if err != nil { if err != nil {
return oops.New(err, "failed to add tags table") return oops.New(err, "failed to add tags tables")
}
_, err = tx.Exec(ctx, `
ALTER TABLE handmade_snippet
DROP CONSTRAINT handmade_snippet_owner_id_fkey,
ALTER owner_id DROP NOT NULL,
ADD CONSTRAINT handmade_snippet_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES auth_user (id) ON DELETE SET NULL;
`)
if err != nil {
return oops.New(err, "failed to update snippet constraints")
} }
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
@ -89,12 +106,64 @@ func (m PersonalProjects) Up(ctx context.Context, tx pgx.Tx) error {
return oops.New(err, "failed to make existing projects official") return oops.New(err, "failed to make existing projects official")
} }
//
// Port "jam snippets" to use a tag
//
jamTagId, err := db.QueryInt(ctx, tx, `INSERT INTO tags (text) VALUES ('wheeljam') RETURNING id`)
if err != nil {
return oops.New(err, "failed to create jam tag")
}
_, err = tx.Exec(ctx,
`
INSERT INTO snippet_tags
SELECT id, $1
FROM handmade_snippet
WHERE is_jam
`,
jamTagId,
)
if err != nil {
return oops.New(err, "failed to add jam tag to jam snippets")
}
_, err = tx.Exec(ctx, `
ALTER TABLE handmade_snippet
DROP is_jam;
`)
if err != nil {
return oops.New(err, "failed to drop is_jam column from snippets")
}
return nil return nil
} }
func (m PersonalProjects) Down(ctx context.Context, tx pgx.Tx) error { func (m PersonalProjects) Down(ctx context.Context, tx pgx.Tx) error {
var err error var err error
_, err = tx.Exec(ctx, `
ALTER TABLE handmade_snippet
ADD is_jam BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE handmade_snippet
SET is_jam = TRUE
WHERE id IN (
SELECT snippet.id
FROM
handmade_snippet AS snippet
JOIN snippet_tags ON snippet.id = snippet_tags.snippet_id
JOIN tags ON snippet_tags.tag_id = tags.id
WHERE
tags.text = 'wheeljam'
);
DELETE FROM tags WHERE text = 'wheeljam';
`)
if err != nil {
return oops.New(err, "failed to revert jam snippets")
}
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
ALTER TABLE handmade_project ALTER TABLE handmade_project
DROP CONSTRAINT slug_syntax, DROP CONSTRAINT slug_syntax,
@ -115,6 +184,17 @@ func (m PersonalProjects) Down(ctx context.Context, tx pgx.Tx) error {
} }
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
ALTER TABLE handmade_snippet
DROP CONSTRAINT handmade_snippet_owner_id_fkey,
ALTER owner_id SET NOT NULL,
ADD CONSTRAINT handmade_snippet_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES auth_user (id) ON DELETE CASCADE;
`)
if err != nil {
return oops.New(err, "failed to revert snippet constraint changes")
}
_, err = tx.Exec(ctx, `
DROP TABLE snippet_tags;
DROP TABLE tags; DROP TABLE tags;
`) `)
if err != nil { if err != nil {

View File

@ -43,7 +43,7 @@ type Project struct {
Slug string `db:"slug"` Slug string `db:"slug"`
Name string `db:"name"` Name string `db:"name"`
Tag string `db:"tag"` TagID *int `db:"tag"`
Blurb string `db:"blurb"` Blurb string `db:"blurb"`
Description string `db:"description"` Description string `db:"description"`
ParsedDescription string `db:"descparsed"` ParsedDescription string `db:"descparsed"`

6
src/models/tag.go Normal file
View File

@ -0,0 +1,6 @@
package models
type Tag struct {
ID int `db:"id"`
Text string `db:"text"`
}

View File

@ -368,6 +368,13 @@ func DiscordUserToTemplate(d *models.DiscordUser) DiscordUser {
} }
} }
func TagToTemplate(t *models.Tag) Tag {
return Tag{
Text: t.Text,
// TODO: Url
}
}
func maybeString(s *string) string { func maybeString(s *string) string {
if s == nil { if s == nil {
return "" return ""

View File

@ -59,4 +59,14 @@
{{ end }} {{ end }}
</div> </div>
{{ end }} {{ end }}
{{ with .Tags }}
<div class="mt3 flex">
{{ range $i, $tag := . }}
<div class="bg-theme-dimmer ph2 pv1 br2 {{ if gt $i 0 }}ml2{{ end }}">
{{ $tag.Text }}
</div>
{{ end }}
</div>
{{ end }}
</div> </div>

View File

@ -268,6 +268,7 @@ type TimelineItem struct {
OwnerName string OwnerName string
OwnerUrl string OwnerUrl string
Tags []Tag
Description template.HTML Description template.HTML
PreviewMedia TimelineItemMedia PreviewMedia TimelineItemMedia
@ -330,3 +331,8 @@ type DiscordUser struct {
Discriminator string Discriminator string
Avatar string Avatar string
} }
type Tag struct {
Text string
Url string
}

View File

@ -14,6 +14,11 @@ func getBaseDataAutocrumb(c *RequestContext, title string) templates.BaseData {
// NOTE(asaf): If you set breadcrumbs, the breadcrumb for the current project will automatically be prepended when necessary. // NOTE(asaf): If you set breadcrumbs, the breadcrumb for the current project will automatically be prepended when necessary.
// If you pass nil, no breadcrumbs will be created. // If you pass nil, no breadcrumbs will be created.
func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadcrumb) templates.BaseData { func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadcrumb) templates.BaseData {
var project models.Project
if c.CurrentProject != nil {
project = *c.CurrentProject
}
var templateUser *templates.User var templateUser *templates.User
var templateSession *templates.Session var templateSession *templates.Session
if c.CurrentUser != nil { if c.CurrentUser != nil {
@ -29,7 +34,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
projectUrl := c.UrlContext.BuildHomepage() projectUrl := c.UrlContext.BuildHomepage()
if breadcrumbs[0].Url != projectUrl { if breadcrumbs[0].Url != projectUrl {
rootBreadcrumb := templates.Breadcrumb{ rootBreadcrumb := templates.Breadcrumb{
Name: c.CurrentProject.Name, Name: project.Name,
Url: projectUrl, Url: projectUrl,
} }
breadcrumbs = append([]templates.Breadcrumb{rootBreadcrumb}, breadcrumbs...) breadcrumbs = append([]templates.Breadcrumb{rootBreadcrumb}, breadcrumbs...)
@ -44,18 +49,18 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
CurrentUrl: c.FullUrl(), CurrentUrl: c.FullUrl(),
CurrentProjectUrl: c.UrlContext.BuildHomepage(), CurrentProjectUrl: c.UrlContext.BuildHomepage(),
LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()), LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()),
ProjectCSSUrl: hmnurl.BuildProjectCSS(c.CurrentProject.Color1), ProjectCSSUrl: hmnurl.BuildProjectCSS(project.Color1),
Project: templates.ProjectToTemplate(c.CurrentProject, c.UrlContext.BuildHomepage(), c.Theme), Project: templates.ProjectToTemplate(&project, c.UrlContext.BuildHomepage(), c.Theme),
User: templateUser, User: templateUser,
Session: templateSession, Session: templateSession,
Notices: notices, Notices: notices,
ReportIssueMailto: "team@handmade.network", ReportIssueMailto: "team@handmade.network",
OpenGraphItems: buildDefaultOpenGraphItems(c.CurrentProject, title), OpenGraphItems: buildDefaultOpenGraphItems(&project, title),
IsProjectPage: !c.CurrentProject.IsHMN(), IsProjectPage: !project.IsHMN(),
Header: templates.Header{ Header: templates.Header{
AdminUrl: hmnurl.BuildAdminApprovalQueue(), // TODO(asaf): Replace with general-purpose admin page AdminUrl: hmnurl.BuildAdminApprovalQueue(), // TODO(asaf): Replace with general-purpose admin page
UserSettingsUrl: hmnurl.BuildUserSettings(""), UserSettingsUrl: hmnurl.BuildUserSettings(""),
@ -86,16 +91,16 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
baseData.Header.UserProfileUrl = hmnurl.BuildUserProfile(c.CurrentUser.Username) baseData.Header.UserProfileUrl = hmnurl.BuildUserProfile(c.CurrentUser.Username)
} }
if !c.CurrentProject.IsHMN() { if !project.IsHMN() {
episodeGuideUrl := "" episodeGuideUrl := ""
defaultTopic, hasAnnotations := config.Config.EpisodeGuide.Projects[c.CurrentProject.Slug] defaultTopic, hasAnnotations := config.Config.EpisodeGuide.Projects[project.Slug]
if hasAnnotations { if hasAnnotations {
episodeGuideUrl = c.UrlContext.BuildEpisodeList(defaultTopic) episodeGuideUrl = c.UrlContext.BuildEpisodeList(defaultTopic)
} }
baseData.Header.Project = &templates.ProjectHeader{ baseData.Header.Project = &templates.ProjectHeader{
HasForums: c.CurrentProject.HasForums(), HasForums: project.HasForums(),
HasBlog: c.CurrentProject.HasBlog(), HasBlog: project.HasBlog(),
HasEpisodeGuide: hasAnnotations, HasEpisodeGuide: hasAnnotations,
ForumsUrl: c.UrlContext.BuildForum(nil, 1), ForumsUrl: c.UrlContext.BuildForum(nil, 1),
BlogUrl: c.UrlContext.BuildBlog(1), BlogUrl: c.UrlContext.BuildBlog(1),

View File

@ -228,35 +228,14 @@ func AtomFeed(c *RequestContext) ResponseData {
feedData.AtomFeedUrl = hmnurl.BuildAtomFeedForShowcase() feedData.AtomFeedUrl = hmnurl.BuildAtomFeedForShowcase()
feedData.FeedUrl = hmnurl.BuildShowcase() feedData.FeedUrl = hmnurl.BuildShowcase()
c.Perf.StartBlock("SQL", "Fetch showcase snippets") snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
type snippetQuery struct { Limit: itemsPerFeed,
Owner models.User `db:"owner"` })
Snippet models.Snippet `db:"snippet"`
Asset *models.Asset `db:"asset"`
DiscordMessage *models.DiscordMessage `db:"discord_message"`
}
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
`
SELECT $columns
FROM
handmade_snippet AS snippet
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
WHERE
NOT snippet.is_jam
ORDER BY snippet.when DESC
LIMIT $1
`,
itemsPerFeed,
)
if err != nil { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets")) return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
} }
snippetQuerySlice := snippetQueryResult.ToSlice() for _, s := range snippets {
for _, s := range snippetQuerySlice { timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
row := s.(*snippetQuery)
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
feedData.Snippets = append(feedData.Snippets, timelineItem) feedData.Snippets = append(feedData.Snippets, timelineItem)
} }
c.Perf.EndBlock() c.Perf.EndBlock()

View File

@ -4,9 +4,7 @@ import (
"net/http" "net/http"
"time" "time"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/templates"
) )
@ -20,35 +18,23 @@ func JamIndex(c *RequestContext) ResponseData {
daysUntil = 0 daysUntil = 0
} }
c.Perf.StartBlock("SQL", "Fetch showcase snippets") var tagIds []int
type snippetQuery struct { jamTag, err := FetchTag(c.Context(), c.Conn, "wheeljam")
Owner models.User `db:"owner"` if err == nil {
Snippet models.Snippet `db:"snippet"` tagIds = []int{jamTag.ID}
Asset *models.Asset `db:"asset"` } else {
DiscordMessage *models.DiscordMessage `db:"discord_message"` c.Logger.Warn().Err(err).Msg("failed to fetch jam tag; will fetch all snippets as a result")
} }
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
` snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
SELECT $columns Tags: tagIds,
FROM })
handmade_snippet AS snippet
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
WHERE
snippet.is_jam
ORDER BY snippet.when DESC
LIMIT 20
`,
)
if err != nil { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch jam snippets")) return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch jam snippets"))
} }
snippetQuerySlice := snippetQueryResult.ToSlice() showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice)) for _, s := range snippets {
for _, s := range snippetQuerySlice { timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
row := s.(*snippetQuery)
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
if timelineItem.CanShowcase { if timelineItem.CanShowcase {
showcaseItems = append(showcaseItems, timelineItem) showcaseItems = append(showcaseItems, timelineItem)
} }

View File

@ -5,7 +5,6 @@ import (
"math" "math"
"net/http" "net/http"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
@ -106,35 +105,15 @@ func Index(c *RequestContext) ResponseData {
} }
c.Perf.EndBlock() c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch showcase snippets") snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
type snippetQuery struct { Limit: 40,
Owner models.User `db:"owner"` })
Snippet models.Snippet `db:"snippet"`
Asset *models.Asset `db:"asset"`
DiscordMessage *models.DiscordMessage `db:"discord_message"`
}
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
`
SELECT $columns
FROM
handmade_snippet AS snippet
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
WHERE
NOT snippet.is_jam
ORDER BY snippet.when DESC
LIMIT 40
`,
)
if err != nil { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets")) return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
} }
snippetQuerySlice := snippetQueryResult.ToSlice() showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice)) for _, s := range snippets {
for _, s := range snippetQuerySlice { timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
row := s.(*snippetQuery)
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
if timelineItem.CanShowcase { if timelineItem.CanShowcase {
showcaseItems = append(showcaseItems, timelineItem) showcaseItems = append(showcaseItems, timelineItem)
} }

View File

@ -3,9 +3,7 @@ package website
import ( import (
"net/http" "net/http"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/templates"
) )
@ -17,34 +15,14 @@ type ShowcaseData struct {
} }
func Showcase(c *RequestContext) ResponseData { func Showcase(c *RequestContext) ResponseData {
c.Perf.StartBlock("SQL", "Fetch showcase snippets") snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{})
type snippetQuery struct {
Owner models.User `db:"owner"`
Snippet models.Snippet `db:"snippet"`
Asset *models.Asset `db:"asset"`
DiscordMessage *models.DiscordMessage `db:"discord_message"`
}
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
`
SELECT $columns
FROM
handmade_snippet AS snippet
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
WHERE
NOT snippet.is_jam
ORDER BY snippet.when DESC
`,
)
if err != nil { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets")) return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
} }
snippetQuerySlice := snippetQueryResult.ToSlice()
showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice)) showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
for _, s := range snippetQuerySlice { for _, s := range snippets {
row := s.(*snippetQuery) timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
if timelineItem.CanShowcase { if timelineItem.CanShowcase {
showcaseItems = append(showcaseItems, timelineItem) showcaseItems = append(showcaseItems, timelineItem)
} }

View File

@ -7,7 +7,6 @@ import (
"strconv" "strconv"
"git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/templates"
) )
@ -30,25 +29,7 @@ func Snippet(c *RequestContext) ResponseData {
return FourOhFour(c) return FourOhFour(c)
} }
c.Perf.StartBlock("SQL", "Fetch snippet") s, err := FetchSnippet(c.Context(), c.Conn, c.CurrentUser, snippetId, SnippetQuery{})
type snippetQuery struct {
Owner models.User `db:"owner"`
Snippet models.Snippet `db:"snippet"`
Asset *models.Asset `db:"asset"`
DiscordMessage *models.DiscordMessage `db:"discord_message"`
}
snippetQueryResult, err := db.QueryOne(c.Context(), c.Conn, snippetQuery{},
`
SELECT $columns
FROM
handmade_snippet AS snippet
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
WHERE snippet.id = $1
`,
snippetId,
)
if err != nil { if err != nil {
if errors.Is(err, db.NotFound) { if errors.Is(err, db.NotFound) {
return FourOhFour(c) return FourOhFour(c)
@ -58,9 +39,7 @@ func Snippet(c *RequestContext) ResponseData {
} }
c.Perf.EndBlock() c.Perf.EndBlock()
snippetData := snippetQueryResult.(*snippetQuery) snippet := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
snippet := SnippetToTimelineItem(&snippetData.Snippet, snippetData.Asset, snippetData.DiscordMessage, &snippetData.Owner, c.Theme)
opengraph := []templates.OpenGraphItem{ opengraph := []templates.OpenGraphItem{
{Property: "og:site_name", Value: "Handmade.Network"}, {Property: "og:site_name", Value: "Handmade.Network"},

View File

@ -0,0 +1,192 @@
package website
import (
"context"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/models"
)
type SnippetQuery struct {
IDs []int
OwnerIDs []int
Tags []int
Limit, Offset int // if empty, no pagination
}
type SnippetAndStuff struct {
Snippet models.Snippet
Owner *models.User
Asset *models.Asset `db:"asset"`
DiscordMessage *models.DiscordMessage `db:"discord_message"`
Tags []*models.Tag
}
func FetchSnippets(
ctx context.Context,
dbConn db.ConnOrTx,
currentUser *models.User,
q SnippetQuery,
) ([]SnippetAndStuff, error) {
perf := ExtractPerf(ctx)
perf.StartBlock("SQL", "Fetch snippets")
defer perf.EndBlock()
tx, err := dbConn.Begin(ctx)
if err != nil {
return nil, oops.New(err, "failed to start transaction")
}
defer tx.Rollback(ctx)
var qb db.QueryBuilder
qb.Add(
`
SELECT $columns
FROM
handmade_snippet AS snippet
LEFT JOIN auth_user AS owner ON snippet.owner_id = owner.id
LEFT JOIN handmade_asset AS asset ON snippet.asset_id = asset.id
LEFT JOIN handmade_discordmessage AS discord_message ON snippet.discord_message_id = discord_message.id
LEFT JOIN snippet_tags ON snippet.id = snippet_tags.snippet_id
LEFT JOIN tags ON snippet_tags.tag_id = tags.id
WHERE
TRUE
`,
)
if len(q.IDs) > 0 {
qb.Add(`AND snippet.id = ANY ($?)`, q.IDs)
}
if len(q.OwnerIDs) > 0 {
qb.Add(`AND snippet.owner_id = ANY ($?)`, q.OwnerIDs)
}
if len(q.Tags) > 0 {
qb.Add(`AND snippet_tags.tag_id = ANY ($?)`, q.Tags)
}
if currentUser == nil {
qb.Add(
`AND owner.status = $? -- snippet owner is Approved`,
models.UserStatusApproved,
)
} else if !currentUser.IsStaff {
qb.Add(
`
AND (
owner.status = $? -- snippet owner is Approved
OR owner.id = $? -- current user is the snippet owner
)
`,
models.UserStatusApproved,
currentUser.ID,
)
}
qb.Add(`ORDER BY snippet.when DESC, snippet.id ASC`)
if q.Limit > 0 {
qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
}
type resultRow struct {
Snippet models.Snippet `db:"snippet"`
Owner *models.User `db:"owner"`
Asset *models.Asset `db:"asset"`
DiscordMessage *models.DiscordMessage `db:"discord_message"`
Tag *models.Tag `db:"tags"`
}
it, err := db.Query(ctx, dbConn, resultRow{}, qb.String(), qb.Args()...)
if err != nil {
return nil, oops.New(err, "failed to fetch threads")
}
iresults := it.ToSlice()
result := make([]SnippetAndStuff, 0, len(iresults)) // allocate extra space because why not
currentSnippetId := -1
for _, iresult := range iresults {
row := *iresult.(*resultRow)
if row.Snippet.ID != currentSnippetId {
// we have moved onto a new snippet; make a new entry
result = append(result, SnippetAndStuff{
Snippet: row.Snippet,
Owner: row.Owner,
Asset: row.Asset,
DiscordMessage: row.DiscordMessage,
// no tags! tags next
})
}
if row.Tag != nil {
result[len(result)-1].Tags = append(result[len(result)-1].Tags, row.Tag)
}
}
err = tx.Commit(ctx)
if err != nil {
return nil, oops.New(err, "failed to commit transaction")
}
return result, nil
}
func FetchSnippet(
ctx context.Context,
dbConn db.ConnOrTx,
currentUser *models.User,
snippetID int,
q SnippetQuery,
) (SnippetAndStuff, error) {
q.IDs = []int{snippetID}
q.Limit = 1
q.Offset = 0
res, err := FetchSnippets(ctx, dbConn, currentUser, q)
if err != nil {
return SnippetAndStuff{}, oops.New(err, "failed to fetch snippet")
}
if len(res) == 0 {
return SnippetAndStuff{}, db.NotFound
}
return res[0], nil
}
func FetchTags(ctx context.Context, dbConn db.ConnOrTx, text []string) ([]*models.Tag, error) {
perf := ExtractPerf(ctx)
perf.StartBlock("SQL", "Fetch snippets")
defer perf.EndBlock()
it, err := db.Query(ctx, dbConn, models.Tag{},
`
SELECT $columns
FROM tags
WHERE text = ANY ($1)
`,
text,
)
if err != nil {
return nil, oops.New(err, "failed to fetch tags")
}
itags := it.ToSlice()
res := make([]*models.Tag, len(itags))
for i, itag := range itags {
tag := itag.(*models.Tag)
res[i] = tag
}
return res, nil
}
func FetchTag(ctx context.Context, dbConn db.ConnOrTx, text string) (*models.Tag, error) {
tags, err := FetchTags(ctx, dbConn, []string{text})
if err != nil {
return nil, err
}
if len(tags) == 0 {
return nil, db.NotFound
}
return tags[0], nil
}

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"regexp" "regexp"
"sort"
"strings" "strings"
"git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/hmnurl"
@ -63,6 +64,7 @@ func SnippetToTimelineItem(
snippet *models.Snippet, snippet *models.Snippet,
asset *models.Asset, asset *models.Asset,
discordMessage *models.DiscordMessage, discordMessage *models.DiscordMessage,
tags []*models.Tag,
owner *models.User, owner *models.User,
currentTheme string, currentTheme string,
) templates.TimelineItem { ) templates.TimelineItem {
@ -106,6 +108,13 @@ func SnippetToTimelineItem(
item.DiscordMessageUrl = discordMessage.Url item.DiscordMessageUrl = discordMessage.Url
} }
sort.Slice(tags, func(i, j int) bool {
return tags[i].Text < tags[j].Text
})
for _, tag := range tags {
item.Tags = append(item.Tags, templates.TagToTemplate(tag))
}
return item return item
} }

View File

@ -136,29 +136,12 @@ func UserProfile(c *RequestContext) ResponseData {
}) })
c.Perf.EndBlock() c.Perf.EndBlock()
type snippetQuery struct { snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
Snippet models.Snippet `db:"snippet"` OwnerIDs: []int{profileUser.ID},
Asset *models.Asset `db:"asset"` })
DiscordMessage *models.DiscordMessage `db:"discord_message"`
}
c.Perf.StartBlock("SQL", "Fetch snippets")
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
`
SELECT $columns
FROM
handmade_snippet AS snippet
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
WHERE
snippet.owner_id = $1
`,
profileUser.ID,
)
if err != nil { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets for user: %s", username)) return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets for user: %s", username))
} }
snippetQuerySlice := snippetQueryResult.ToSlice()
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch subforum tree") c.Perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn) subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
@ -166,7 +149,7 @@ func UserProfile(c *RequestContext) ResponseData {
c.Perf.EndBlock() c.Perf.EndBlock()
c.Perf.StartBlock("PROFILE", "Construct timeline items") c.Perf.StartBlock("PROFILE", "Construct timeline items")
timelineItems := make([]templates.TimelineItem, 0, len(posts)+len(snippetQuerySlice)) timelineItems := make([]templates.TimelineItem, 0, len(posts)+len(snippets))
for _, post := range posts { for _, post := range posts {
timelineItems = append(timelineItems, PostToTimelineItem( timelineItems = append(timelineItems, PostToTimelineItem(
@ -179,12 +162,12 @@ func UserProfile(c *RequestContext) ResponseData {
)) ))
} }
for _, snippetRow := range snippetQuerySlice { for _, s := range snippets {
snippetData := snippetRow.(*snippetQuery)
item := SnippetToTimelineItem( item := SnippetToTimelineItem(
&snippetData.Snippet, &s.Snippet,
snippetData.Asset, s.Asset,
snippetData.DiscordMessage, s.DiscordMessage,
s.Tags,
profileUser, profileUser,
c.Theme, c.Theme,
) )