From 0cc879df21832721b1c0fb030ce068bc5719a80e Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Thu, 11 Nov 2021 11:00:46 -0800 Subject: [PATCH] Add tags to snippets on timelines --- src/discord/showcase.go | 7 +- .../2021-11-06T033930Z_PersonalProjects.go | 84 +++++++- src/models/project.go | 2 +- src/models/tag.go | 6 + src/templates/mapping.go | 7 + src/templates/src/include/timeline_item.html | 10 + src/templates/types.go | 6 + src/website/base_data.go | 23 ++- src/website/feed.go | 31 +-- src/website/jam.go | 40 ++-- src/website/landing.go | 33 +-- src/website/showcase.go | 32 +-- src/website/snippet.go | 25 +-- src/website/snippet_helper.go | 192 ++++++++++++++++++ src/website/timeline_helper.go | 9 + src/website/user.go | 35 +--- 16 files changed, 373 insertions(+), 169 deletions(-) create mode 100644 src/models/tag.go create mode 100644 src/website/snippet_helper.go diff --git a/src/discord/showcase.go b/src/discord/showcase.go index 185c0e3e..7ca9f3ea 100644 --- a/src/discord/showcase.go +++ b/src/discord/showcase.go @@ -64,7 +64,12 @@ func (bot *botInstance) processShowcaseMsg(ctx context.Context, msg *Message, is } 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 { return oops.New(err, "failed to mark snippet as a jam snippet") } diff --git a/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go b/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go index 4f5d3cf1..552319b0 100644 --- a/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go +++ b/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go @@ -4,6 +4,8 @@ import ( "context" "time" + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/migration/types" "git.handmade.network/hmn/hmn/src/oops" "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, text VARCHAR(20) NOT NULL ); + CREATE INDEX tags_by_text ON tags (text); ALTER TABLE tags ADD CONSTRAINT tag_syntax CHECK ( 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 { - 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, ` @@ -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") } + // + // 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 } func (m PersonalProjects) Down(ctx context.Context, tx pgx.Tx) 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, ` ALTER TABLE handmade_project DROP CONSTRAINT slug_syntax, @@ -115,6 +184,17 @@ func (m PersonalProjects) Down(ctx context.Context, tx pgx.Tx) error { } _, 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; `) if err != nil { diff --git a/src/models/project.go b/src/models/project.go index 24dc591f..62824928 100644 --- a/src/models/project.go +++ b/src/models/project.go @@ -43,7 +43,7 @@ type Project struct { Slug string `db:"slug"` Name string `db:"name"` - Tag string `db:"tag"` + TagID *int `db:"tag"` Blurb string `db:"blurb"` Description string `db:"description"` ParsedDescription string `db:"descparsed"` diff --git a/src/models/tag.go b/src/models/tag.go new file mode 100644 index 00000000..81871c3d --- /dev/null +++ b/src/models/tag.go @@ -0,0 +1,6 @@ +package models + +type Tag struct { + ID int `db:"id"` + Text string `db:"text"` +} diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 5bf86ac6..30cd2db7 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -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 { if s == nil { return "" diff --git a/src/templates/src/include/timeline_item.html b/src/templates/src/include/timeline_item.html index 62f40491..285fb6b9 100644 --- a/src/templates/src/include/timeline_item.html +++ b/src/templates/src/include/timeline_item.html @@ -59,4 +59,14 @@ {{ end }} {{ end }} + + {{ with .Tags }} +
+ {{ range $i, $tag := . }} +
+ {{ $tag.Text }} +
+ {{ end }} +
+ {{ end }} diff --git a/src/templates/types.go b/src/templates/types.go index 14c25aed..5c86f581 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -268,6 +268,7 @@ type TimelineItem struct { OwnerName string OwnerUrl string + Tags []Tag Description template.HTML PreviewMedia TimelineItemMedia @@ -330,3 +331,8 @@ type DiscordUser struct { Discriminator string Avatar string } + +type Tag struct { + Text string + Url string +} diff --git a/src/website/base_data.go b/src/website/base_data.go index 350b66aa..fa4bc574 100644 --- a/src/website/base_data.go +++ b/src/website/base_data.go @@ -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. // If you pass nil, no breadcrumbs will be created. 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 templateSession *templates.Session if c.CurrentUser != nil { @@ -29,7 +34,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc projectUrl := c.UrlContext.BuildHomepage() if breadcrumbs[0].Url != projectUrl { rootBreadcrumb := templates.Breadcrumb{ - Name: c.CurrentProject.Name, + Name: project.Name, Url: projectUrl, } breadcrumbs = append([]templates.Breadcrumb{rootBreadcrumb}, breadcrumbs...) @@ -44,18 +49,18 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc CurrentUrl: c.FullUrl(), CurrentProjectUrl: c.UrlContext.BuildHomepage(), 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, Session: templateSession, Notices: notices, ReportIssueMailto: "team@handmade.network", - OpenGraphItems: buildDefaultOpenGraphItems(c.CurrentProject, title), + OpenGraphItems: buildDefaultOpenGraphItems(&project, title), - IsProjectPage: !c.CurrentProject.IsHMN(), + IsProjectPage: !project.IsHMN(), Header: templates.Header{ AdminUrl: hmnurl.BuildAdminApprovalQueue(), // TODO(asaf): Replace with general-purpose admin page UserSettingsUrl: hmnurl.BuildUserSettings(""), @@ -86,16 +91,16 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc baseData.Header.UserProfileUrl = hmnurl.BuildUserProfile(c.CurrentUser.Username) } - if !c.CurrentProject.IsHMN() { + if !project.IsHMN() { episodeGuideUrl := "" - defaultTopic, hasAnnotations := config.Config.EpisodeGuide.Projects[c.CurrentProject.Slug] + defaultTopic, hasAnnotations := config.Config.EpisodeGuide.Projects[project.Slug] if hasAnnotations { episodeGuideUrl = c.UrlContext.BuildEpisodeList(defaultTopic) } baseData.Header.Project = &templates.ProjectHeader{ - HasForums: c.CurrentProject.HasForums(), - HasBlog: c.CurrentProject.HasBlog(), + HasForums: project.HasForums(), + HasBlog: project.HasBlog(), HasEpisodeGuide: hasAnnotations, ForumsUrl: c.UrlContext.BuildForum(nil, 1), BlogUrl: c.UrlContext.BuildBlog(1), diff --git a/src/website/feed.go b/src/website/feed.go index 5250d478..4baa3ea1 100644 --- a/src/website/feed.go +++ b/src/website/feed.go @@ -228,35 +228,14 @@ func AtomFeed(c *RequestContext) ResponseData { feedData.AtomFeedUrl = hmnurl.BuildAtomFeedForShowcase() feedData.FeedUrl = hmnurl.BuildShowcase() - c.Perf.StartBlock("SQL", "Fetch showcase snippets") - 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 - LIMIT $1 - `, - itemsPerFeed, - ) + snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{ + Limit: itemsPerFeed, + }) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets")) } - snippetQuerySlice := snippetQueryResult.ToSlice() - for _, s := range snippetQuerySlice { - row := s.(*snippetQuery) - timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme) + for _, s := range snippets { + timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme) feedData.Snippets = append(feedData.Snippets, timelineItem) } c.Perf.EndBlock() diff --git a/src/website/jam.go b/src/website/jam.go index 9789d04e..aadb3051 100644 --- a/src/website/jam.go +++ b/src/website/jam.go @@ -4,9 +4,7 @@ import ( "net/http" "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" ) @@ -20,35 +18,23 @@ func JamIndex(c *RequestContext) ResponseData { daysUntil = 0 } - c.Perf.StartBlock("SQL", "Fetch showcase snippets") - type snippetQuery struct { - Owner models.User `db:"owner"` - Snippet models.Snippet `db:"snippet"` - Asset *models.Asset `db:"asset"` - DiscordMessage *models.DiscordMessage `db:"discord_message"` + var tagIds []int + jamTag, err := FetchTag(c.Context(), c.Conn, "wheeljam") + if err == nil { + tagIds = []int{jamTag.ID} + } else { + 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{}, - ` - 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.is_jam - ORDER BY snippet.when DESC - LIMIT 20 - `, - ) + + snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{ + Tags: tagIds, + }) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch jam snippets")) } - snippetQuerySlice := snippetQueryResult.ToSlice() - showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice)) - for _, s := range snippetQuerySlice { - row := s.(*snippetQuery) - timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme) + showcaseItems := make([]templates.TimelineItem, 0, len(snippets)) + for _, s := range snippets { + timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme) if timelineItem.CanShowcase { showcaseItems = append(showcaseItems, timelineItem) } diff --git a/src/website/landing.go b/src/website/landing.go index 188655d6..6803e302 100644 --- a/src/website/landing.go +++ b/src/website/landing.go @@ -5,7 +5,6 @@ import ( "math" "net/http" - "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" @@ -106,35 +105,15 @@ func Index(c *RequestContext) ResponseData { } c.Perf.EndBlock() - c.Perf.StartBlock("SQL", "Fetch showcase snippets") - 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 - LIMIT 40 - `, - ) + snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{ + Limit: 40, + }) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets")) } - snippetQuerySlice := snippetQueryResult.ToSlice() - showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice)) - for _, s := range snippetQuerySlice { - row := s.(*snippetQuery) - timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme) + showcaseItems := make([]templates.TimelineItem, 0, len(snippets)) + for _, s := range snippets { + timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme) if timelineItem.CanShowcase { showcaseItems = append(showcaseItems, timelineItem) } diff --git a/src/website/showcase.go b/src/website/showcase.go index 1750c90d..18d5eceb 100644 --- a/src/website/showcase.go +++ b/src/website/showcase.go @@ -3,9 +3,7 @@ package website import ( "net/http" - "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" ) @@ -17,34 +15,14 @@ type ShowcaseData struct { } func Showcase(c *RequestContext) ResponseData { - c.Perf.StartBlock("SQL", "Fetch showcase snippets") - 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 - `, - ) + snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{}) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets")) } - snippetQuerySlice := snippetQueryResult.ToSlice() - showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice)) - for _, s := range snippetQuerySlice { - row := s.(*snippetQuery) - timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme) + + showcaseItems := make([]templates.TimelineItem, 0, len(snippets)) + for _, s := range snippets { + timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme) if timelineItem.CanShowcase { showcaseItems = append(showcaseItems, timelineItem) } diff --git a/src/website/snippet.go b/src/website/snippet.go index f41fb0c0..bdeca517 100644 --- a/src/website/snippet.go +++ b/src/website/snippet.go @@ -7,7 +7,6 @@ import ( "strconv" "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/templates" ) @@ -30,25 +29,7 @@ func Snippet(c *RequestContext) ResponseData { return FourOhFour(c) } - c.Perf.StartBlock("SQL", "Fetch snippet") - 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, - ) + s, err := FetchSnippet(c.Context(), c.Conn, c.CurrentUser, snippetId, SnippetQuery{}) if err != nil { if errors.Is(err, db.NotFound) { return FourOhFour(c) @@ -58,9 +39,7 @@ func Snippet(c *RequestContext) ResponseData { } c.Perf.EndBlock() - snippetData := snippetQueryResult.(*snippetQuery) - - snippet := SnippetToTimelineItem(&snippetData.Snippet, snippetData.Asset, snippetData.DiscordMessage, &snippetData.Owner, c.Theme) + snippet := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme) opengraph := []templates.OpenGraphItem{ {Property: "og:site_name", Value: "Handmade.Network"}, diff --git a/src/website/snippet_helper.go b/src/website/snippet_helper.go new file mode 100644 index 00000000..50d748ab --- /dev/null +++ b/src/website/snippet_helper.go @@ -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 +} diff --git a/src/website/timeline_helper.go b/src/website/timeline_helper.go index ef3ca3c1..e24cb058 100644 --- a/src/website/timeline_helper.go +++ b/src/website/timeline_helper.go @@ -4,6 +4,7 @@ import ( "fmt" "html/template" "regexp" + "sort" "strings" "git.handmade.network/hmn/hmn/src/hmnurl" @@ -63,6 +64,7 @@ func SnippetToTimelineItem( snippet *models.Snippet, asset *models.Asset, discordMessage *models.DiscordMessage, + tags []*models.Tag, owner *models.User, currentTheme string, ) templates.TimelineItem { @@ -106,6 +108,13 @@ func SnippetToTimelineItem( 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 } diff --git a/src/website/user.go b/src/website/user.go index 597aa3b3..fa00eccc 100644 --- a/src/website/user.go +++ b/src/website/user.go @@ -136,29 +136,12 @@ func UserProfile(c *RequestContext) ResponseData { }) c.Perf.EndBlock() - type snippetQuery struct { - Snippet models.Snippet `db:"snippet"` - 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, - ) + snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{ + OwnerIDs: []int{profileUser.ID}, + }) if err != nil { 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") subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn) @@ -166,7 +149,7 @@ func UserProfile(c *RequestContext) ResponseData { c.Perf.EndBlock() 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 { timelineItems = append(timelineItems, PostToTimelineItem( @@ -179,12 +162,12 @@ func UserProfile(c *RequestContext) ResponseData { )) } - for _, snippetRow := range snippetQuerySlice { - snippetData := snippetRow.(*snippetQuery) + for _, s := range snippets { item := SnippetToTimelineItem( - &snippetData.Snippet, - snippetData.Asset, - snippetData.DiscordMessage, + &s.Snippet, + s.Asset, + s.DiscordMessage, + s.Tags, profileUser, c.Theme, )