diff --git a/local/resetdb.sh b/local/resetdb.sh index 7f1e7401..ba5af70f 100755 --- a/local/resetdb.sh +++ b/local/resetdb.sh @@ -10,7 +10,7 @@ set -euxo pipefail THIS_PATH=$(pwd) #BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta' - BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta' +BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta' pushd $BETA_PATH docker-compose down -v @@ -19,4 +19,5 @@ pushd $BETA_PATH docker-compose exec postgres bash -c "psql -U postgres -c \"CREATE ROLE hmn CREATEDB LOGIN PASSWORD 'password';\"" popd +#go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-09-06 go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-10-23 diff --git a/public/js/showcase.js b/public/js/showcase.js index d2a2e9e9..1f39ab09 100644 --- a/public/js/showcase.js +++ b/public/js/showcase.js @@ -7,6 +7,7 @@ const TimelineMediaTypes = { const showcaseItemTemplate = makeTemplateCloner("showcase_item"); const modalTemplate = makeTemplateCloner("timeline_modal"); +const tagTemplate = makeTemplateCloner("timeline_item_tag"); function showcaseTimestamp(rawDate) { const date = new Date(rawDate*1000); @@ -95,6 +96,17 @@ function makeShowcaseItem(timelineItem) { modalEl.date.textContent = timestamp; modalEl.date.setAttribute("href", timelineItem.snippet_url); + if (timelineItem.tags.length === 0) { + modalEl.tags.remove(); + } else { + for (const tag of timelineItem.tags) { + const tagItem = tagTemplate(); + tagItem.tag.innerText = tag.text; + + modalEl.tags.appendChild(tagItem.root); + } + } + modalEl.discord_link.href = timelineItem.discord_message_url; function close() { diff --git a/src/db/db.go b/src/db/db.go index 32996e2f..01c20c96 100644 --- a/src/db/db.go +++ b/src/db/db.go @@ -60,6 +60,12 @@ type ConnOrTx interface { Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) + + // Both raw database connections and transactions in pgx can begin/commit + // transactions. For database connections it does the obvious thing; for + // transactions it creates a "pseudo-nested transaction" but conceptually + // works the same. See the documentation of pgx.Tx.Begin. + Begin(ctx context.Context) (pgx.Tx, error) } var connInfo = pgtype.NewConnInfo() 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/hmnurl/hmnurl.go b/src/hmnurl/hmnurl.go index 354f4d1f..c5103b79 100644 --- a/src/hmnurl/hmnurl.go +++ b/src/hmnurl/hmnurl.go @@ -6,8 +6,9 @@ import ( "regexp" "time" - "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/models" + + "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/oops" ) @@ -62,34 +63,31 @@ func GetBaseHost() string { return baseUrlParsed.Host } +type UrlContext struct { + PersonalProject bool + ProjectID int + ProjectSlug string + ProjectName string +} + +var HMNProjectContext = UrlContext{ + PersonalProject: false, + ProjectID: models.HMNProjectID, + ProjectSlug: models.HMNProjectSlug, +} + func Url(path string, query []Q) string { - return ProjectUrl(path, query, "") + return UrlWithFragment(path, query, "") } -func ProjectUrl(path string, query []Q, slug string) string { - return ProjectUrlWithFragment(path, query, slug, "") +func UrlWithFragment(path string, query []Q, fragment string) string { + return HMNProjectContext.UrlWithFragment(path, query, fragment) } -func ProjectUrlWithFragment(path string, query []Q, slug string, fragment string) string { - subdomain := slug - if slug == models.HMNProjectSlug { - subdomain = "" - } - - host := baseUrlParsed.Host - if len(subdomain) > 0 { - host = slug + "." + host - } - - url := url.URL{ - Scheme: baseUrlParsed.Scheme, - Host: host, - Path: trim(path), - RawQuery: encodeQuery(query), - Fragment: fragment, - } - - return url.String() +func (c *UrlContext) RewriteProjectUrl(u *url.URL) string { + // we need to strip anything matching the personal project regex to get the base path + match := RegexPersonalProject.FindString(u.Path) + return c.Url(u.Path[len(match):], QFromURL(u)) } func trim(path string) string { diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 68568f52..8cc63eb0 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -21,12 +21,11 @@ var RegexOldHome = regexp.MustCompile("^/home$") var RegexHomepage = regexp.MustCompile("^/$") func BuildHomepage() string { - return Url("/", nil) + return HMNProjectContext.BuildHomepage() } -func BuildProjectHomepage(projectSlug string) string { - defer CatchPanic() - return ProjectUrl("/", nil, projectSlug) +func (c *UrlContext) BuildHomepage() string { + return c.Url("/", nil) } var RegexShowcase = regexp.MustCompile("^/showcase$") @@ -97,8 +96,6 @@ func BuildRegistrationSuccess() string { return Url("/registered_successfully", nil) } -// TODO(asaf): Delete the old version a bit after launch -var RegexOldEmailConfirmation = regexp.MustCompile(`^/_register/confirm/(?P[\w\ \.\,\-@\+\_]+)/(?P[\d\w]+)/(?P.+)[\/]?$`) var RegexEmailConfirmation = regexp.MustCompile("^/email_confirmation/(?P[^/]+)/(?P[^/]+)$") func BuildEmailConfirmation(username, token string) string { @@ -198,7 +195,7 @@ func BuildUserProfile(username string) string { var RegexUserSettings = regexp.MustCompile(`^/settings$`) func BuildUserSettings(section string) string { - return ProjectUrlWithFragment("/settings", nil, "", section) + return UrlWithFragment("/settings", nil, section) } /* @@ -295,20 +292,19 @@ func BuildProjectNew() string { return Url("/projects/new", nil) } -var RegexProjectNotApproved = regexp.MustCompile("^/p/(?P.+)$") +var RegexPersonalProject = regexp.MustCompile("^/p/(?P[0-9]+)(/(?P[a-zA-Z0-9-]+))?") -func BuildProjectNotApproved(slug string) string { +func BuildPersonalProject(id int, slug string) string { defer CatchPanic() - - return Url(fmt.Sprintf("/p/%s", slug), nil) + return Url(fmt.Sprintf("/p/%d/%s", id, slug), nil) } -var RegexProjectEdit = regexp.MustCompile("^/p/(?P.+)/edit$") +var RegexProjectEdit = regexp.MustCompile("^/edit$") -func BuildProjectEdit(slug string, section string) string { +func (c *UrlContext) BuildProjectEdit(section string) string { defer CatchPanic() - return ProjectUrlWithFragment(fmt.Sprintf("/p/%s/edit", slug), nil, "", section) + return c.UrlWithFragment("/edit", nil, section) } /* @@ -370,7 +366,50 @@ func BuildPodcastEpisodeFile(filename string) string { // Make sure to match Thread before Subforum in the router. var RegexForum = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?(/(?P\d+))?$`) -func BuildForum(projectSlug string, subforums []string, page int) string { +func (c *UrlContext) Url(path string, query []Q) string { + return c.UrlWithFragment(path, query, "") +} + +func (c *UrlContext) UrlWithFragment(path string, query []Q, fragment string) string { + if c == nil { + logging.Warn().Stack().Msg("URL context was nil; defaulting to the HMN URL context") + c = &HMNProjectContext + } + + if c.PersonalProject { + url := url.URL{ + Scheme: baseUrlParsed.Scheme, + Host: baseUrlParsed.Host, + Path: fmt.Sprintf("p/%d/%s/%s", c.ProjectID, models.GeneratePersonalProjectSlug(c.ProjectName), trim(path)), + RawQuery: encodeQuery(query), + Fragment: fragment, + } + + return url.String() + } else { + subdomain := c.ProjectSlug + if c.ProjectSlug == models.HMNProjectSlug { + subdomain = "" + } + + host := baseUrlParsed.Host + if len(subdomain) > 0 { + host = c.ProjectSlug + "." + host + } + + url := url.URL{ + Scheme: baseUrlParsed.Scheme, + Host: host, + Path: trim(path), + RawQuery: encodeQuery(query), + Fragment: fragment, + } + + return url.String() + } +} + +func (c *UrlContext) BuildForum(subforums []string, page int) string { defer CatchPanic() if page < 1 { panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page)) @@ -383,13 +422,13 @@ func BuildForum(projectSlug string, subforums []string, page int) string { builder.WriteString(strconv.Itoa(page)) } - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexForumNewThread = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/t/new$`) var RegexForumNewThreadSubmit = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/t/new/submit$`) -func BuildForumNewThread(projectSlug string, subforums []string, submit bool) string { +func (c *UrlContext) BuildForumNewThread(subforums []string, submit bool) string { defer CatchPanic() builder := buildSubforumPath(subforums) builder.WriteString("/t/new") @@ -397,59 +436,59 @@ func BuildForumNewThread(projectSlug string, subforums []string, submit bool) st builder.WriteString("/submit") } - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexForumThread = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/t/(?P\d+)(-([^/]+))?(/(?P\d+))?$`) -func BuildForumThread(projectSlug string, subforums []string, threadId int, title string, page int) string { +func (c *UrlContext) BuildForumThread(subforums []string, threadId int, title string, page int) string { defer CatchPanic() builder := buildForumThreadPath(subforums, threadId, title, page) - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } -func BuildForumThreadWithPostHash(projectSlug string, subforums []string, threadId int, title string, page int, postId int) string { +func (c *UrlContext) BuildForumThreadWithPostHash(subforums []string, threadId int, title string, page int, postId int) string { defer CatchPanic() builder := buildForumThreadPath(subforums, threadId, title, page) - return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId)) + return c.UrlWithFragment(builder.String(), nil, strconv.Itoa(postId)) } var RegexForumPost = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/t/(?P\d+)/p/(?P\d+)$`) -func BuildForumPost(projectSlug string, subforums []string, threadId int, postId int) string { +func (c *UrlContext) BuildForumPost(subforums []string, threadId int, postId int) string { defer CatchPanic() builder := buildForumPostPath(subforums, threadId, postId) - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexForumPostDelete = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/t/(?P\d+)/p/(?P\d+)/delete$`) -func BuildForumPostDelete(projectSlug string, subforums []string, threadId int, postId int) string { +func (c *UrlContext) BuildForumPostDelete(subforums []string, threadId int, postId int) string { defer CatchPanic() builder := buildForumPostPath(subforums, threadId, postId) builder.WriteString("/delete") - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexForumPostEdit = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/t/(?P\d+)/p/(?P\d+)/edit$`) -func BuildForumPostEdit(projectSlug string, subforums []string, threadId int, postId int) string { +func (c *UrlContext) BuildForumPostEdit(subforums []string, threadId int, postId int) string { defer CatchPanic() builder := buildForumPostPath(subforums, threadId, postId) builder.WriteString("/edit") - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexForumPostReply = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/t/(?P\d+)/p/(?P\d+)/reply$`) -func BuildForumPostReply(projectSlug string, subforums []string, threadId int, postId int) string { +func (c *UrlContext) BuildForumPostReply(subforums []string, threadId int, postId int) string { defer CatchPanic() builder := buildForumPostPath(subforums, threadId, postId) builder.WriteString("/reply") - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexWikiArticle = regexp.MustCompile(`^/wiki/(?P\d+)(-([^/]+))?$`) @@ -462,7 +501,7 @@ var RegexBlogsRedirect = regexp.MustCompile(`^/blogs(?P.*)`) var RegexBlog = regexp.MustCompile(`^/blog(/(?P\d+))?$`) -func BuildBlog(projectSlug string, page int) string { +func (c *UrlContext) BuildBlog(page int) string { defer CatchPanic() if page < 1 { panic(oops.New(nil, "Invalid blog page (%d), must be >= 1", page)) @@ -473,63 +512,63 @@ func BuildBlog(projectSlug string, page int) string { path += "/" + strconv.Itoa(page) } - return ProjectUrl(path, nil, projectSlug) + return c.Url(path, nil) } var RegexBlogThread = regexp.MustCompile(`^/blog/p/(?P\d+)(-([^/]+))?$`) -func BuildBlogThread(projectSlug string, threadId int, title string) string { +func (c *UrlContext) BuildBlogThread(threadId int, title string) string { defer CatchPanic() builder := buildBlogThreadPath(threadId, title) - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } -func BuildBlogThreadWithPostHash(projectSlug string, threadId int, title string, postId int) string { +func (c *UrlContext) BuildBlogThreadWithPostHash(threadId int, title string, postId int) string { defer CatchPanic() builder := buildBlogThreadPath(threadId, title) - return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId)) + return c.UrlWithFragment(builder.String(), nil, strconv.Itoa(postId)) } var RegexBlogNewThread = regexp.MustCompile(`^/blog/new$`) -func BuildBlogNewThread(projectSlug string) string { +func (c *UrlContext) BuildBlogNewThread() string { defer CatchPanic() - return ProjectUrl("/blog/new", nil, projectSlug) + return c.Url("/blog/new", nil) } var RegexBlogPost = regexp.MustCompile(`^/blog/p/(?P\d+)/e/(?P\d+)$`) -func BuildBlogPost(projectSlug string, threadId int, postId int) string { +func (c *UrlContext) BuildBlogPost(threadId int, postId int) string { defer CatchPanic() builder := buildBlogPostPath(threadId, postId) - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexBlogPostDelete = regexp.MustCompile(`^/blog/p/(?P\d+)/e/(?P\d+)/delete$`) -func BuildBlogPostDelete(projectSlug string, threadId int, postId int) string { +func (c *UrlContext) BuildBlogPostDelete(threadId int, postId int) string { defer CatchPanic() builder := buildBlogPostPath(threadId, postId) builder.WriteString("/delete") - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexBlogPostEdit = regexp.MustCompile(`^/blog/p/(?P\d+)/e/(?P\d+)/edit$`) -func BuildBlogPostEdit(projectSlug string, threadId int, postId int) string { +func (c *UrlContext) BuildBlogPostEdit(threadId int, postId int) string { defer CatchPanic() builder := buildBlogPostPath(threadId, postId) builder.WriteString("/edit") - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexBlogPostReply = regexp.MustCompile(`^/blog/p/(?P\d+)/e/(?P\d+)/reply$`) -func BuildBlogPostReply(projectSlug string, threadId int, postId int) string { +func (c *UrlContext) BuildBlogPostReply(threadId int, postId int) string { defer CatchPanic() builder := buildBlogPostPath(threadId, postId) builder.WriteString("/reply") - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } /* @@ -583,7 +622,7 @@ func BuildLibraryResource(resourceId int) string { var RegexEpisodeList = regexp.MustCompile(`^/episode(/(?P[^/]+))?$`) -func BuildEpisodeList(projectSlug string, topic string) string { +func (c *UrlContext) BuildEpisodeList(topic string) string { defer CatchPanic() var builder strings.Builder @@ -592,21 +631,21 @@ func BuildEpisodeList(projectSlug string, topic string) string { builder.WriteString("/") builder.WriteString(topic) } - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } var RegexEpisode = regexp.MustCompile(`^/episode/(?P[^/]+)/(?P[^/]+)$`) -func BuildEpisode(projectSlug string, topic string, episode string) string { +func (c *UrlContext) BuildEpisode(topic string, episode string) string { defer CatchPanic() - return ProjectUrl(fmt.Sprintf("/episode/%s/%s", topic, episode), nil, projectSlug) + return c.Url(fmt.Sprintf("/episode/%s/%s", topic, episode), nil) } var RegexCineraIndex = regexp.MustCompile(`^/(?P[^/]+).index$`) -func BuildCineraIndex(projectSlug string, topic string) string { +func (c *UrlContext) BuildCineraIndex(topic string) string { defer CatchPanic() - return ProjectUrl(fmt.Sprintf("/%s.index", topic), nil, projectSlug) + return c.Url(fmt.Sprintf("/%s.index", topic), nil) } /* @@ -638,8 +677,8 @@ func BuildDiscordShowcaseBacklog() string { var RegexAssetUpload = regexp.MustCompile("^/upload_asset$") // NOTE(asaf): Providing the projectSlug avoids any CORS problems. -func BuildAssetUpload(projectSlug string) string { - return ProjectUrl("/upload_asset", nil, projectSlug) +func (c *UrlContext) BuildAssetUpload() string { + return c.Url("/upload_asset", nil) } /* @@ -718,7 +757,7 @@ func BuildUserFile(filepath string) string { var RegexForumMarkRead = regexp.MustCompile(`^/markread/(?P\d+)$`) // NOTE(asaf): subforumId == 0 means ALL SUBFORUMS -func BuildForumMarkRead(projectSlug string, subforumId int) string { +func (c *UrlContext) BuildForumMarkRead(subforumId int) string { defer CatchPanic() if subforumId < 0 { panic(oops.New(nil, "Invalid subforum ID (%d), must be >= 0", subforumId)) @@ -728,10 +767,10 @@ func BuildForumMarkRead(projectSlug string, subforumId int) string { builder.WriteString("/markread/") builder.WriteString(strconv.Itoa(subforumId)) - return ProjectUrl(builder.String(), nil, projectSlug) + return c.Url(builder.String(), nil) } -var RegexCatchAll = regexp.MustCompile("") +var RegexCatchAll = regexp.MustCompile("^") /* * Helper functions diff --git a/src/migration/migrationTemplate.txt b/src/migration/migrationTemplate.txt index 7656e2fd..53b4e355 100644 --- a/src/migration/migrationTemplate.txt +++ b/src/migration/migrationTemplate.txt @@ -5,7 +5,6 @@ import ( "time" "git.handmade.network/hmn/hmn/src/migration/types" - "git.handmade.network/hmn/hmn/src/oops" "github.com/jackc/pgx/v4" ) diff --git a/src/migration/migrations/2021-11-06T033835Z_CleanUpProjects.go b/src/migration/migrations/2021-11-06T033835Z_CleanUpProjects.go new file mode 100644 index 00000000..5bc0a552 --- /dev/null +++ b/src/migration/migrations/2021-11-06T033835Z_CleanUpProjects.go @@ -0,0 +1,68 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4" +) + +func init() { + registerMigration(CleanUpProjects{}) +} + +type CleanUpProjects struct{} + +func (m CleanUpProjects) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 11, 6, 3, 38, 35, 0, time.UTC)) +} + +func (m CleanUpProjects) Name() string { + return "CleanUpProjects" +} + +func (m CleanUpProjects) Description() string { + return "Clean up projects with data violating our new constraints" +} + +func (m CleanUpProjects) Up(ctx context.Context, tx pgx.Tx) error { + var err error + + _, err = tx.Exec(ctx, ` + DELETE FROM handmade_project WHERE id IN (91, 92); + DELETE FROM handmade_communicationchoicelist + WHERE project_id IN (91, 92); + DELETE FROM handmade_project_languages + WHERE project_id IN (91, 92); + DELETE FROM handmade_project_screenshots + WHERE project_id IN (91, 92); + + UPDATE handmade_project + SET slug = 'hmh-notes' + WHERE slug = 'hmh_notes'; + `) + if err != nil { + return oops.New(err, "failed to patch up project slugs") + } + + return nil +} + +func (m CleanUpProjects) Down(ctx context.Context, tx pgx.Tx) error { + var err error + + _, err = tx.Exec(ctx, ` + -- Don't bother restoring those old projects + + UPDATE handmade_project + SET slug = 'hmh_notes' + WHERE slug = 'hmh-notes'; + `) + if err != nil { + return oops.New(err, "failed to restore project slugs") + } + + return nil +} diff --git a/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go b/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go new file mode 100644 index 00000000..552319b0 --- /dev/null +++ b/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go @@ -0,0 +1,205 @@ +package migrations + +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" +) + +func init() { + registerMigration(PersonalProjects{}) +} + +type PersonalProjects struct{} + +func (m PersonalProjects) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 11, 6, 3, 39, 30, 0, time.UTC)) +} + +func (m PersonalProjects) Name() string { + return "PersonalProjects" +} + +func (m PersonalProjects) Description() string { + return "Add data model for personal projects / tags" +} + +func (m PersonalProjects) Up(ctx context.Context, tx pgx.Tx) error { + var err error + + _, err = tx.Exec(ctx, ` + CREATE TABLE tags ( + 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 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 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, ` + ALTER TABLE handmade_project + DROP featurevotes, + DROP parent_id, + DROP quota, + DROP quota_used, + DROP standalone, + ALTER flags TYPE BOOLEAN USING flags > 0; + + ALTER TABLE handmade_project RENAME flags TO hidden; + `) + if err != nil { + return oops.New(err, "failed to clean up existing fields") + } + + _, err = tx.Exec(ctx, ` + ALTER TABLE handmade_project + ADD personal BOOLEAN NOT NULL DEFAULT TRUE, + ADD tag INT REFERENCES tags (id); + `) + if err != nil { + return oops.New(err, "failed to add new fields") + } + + _, err = tx.Exec(ctx, ` + ALTER TABLE handmade_project + ADD CONSTRAINT slug_syntax CHECK ( + slug ~ '^([a-z0-9]+(-[a-z0-9]+)*)?$' + ); + `) + if err != nil { + return oops.New(err, "failed to add check constraints") + } + + _, err = tx.Exec(ctx, ` + UPDATE handmade_project + SET personal = FALSE; + `) + if err != nil { + 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, + DROP personal, + DROP tag, + ADD featurevotes INT NOT NULL DEFAULT 0, + -- no projects actually have a parent id so thankfully no further updates to do + ADD parent_id INT REFERENCES handmade_project (id) ON DELETE SET NULL, + ADD quota INT NOT NULL DEFAULT 0, + ADD quota_used INT NOT NULL DEFAULT 0, + ADD standalone BOOLEAN NOT NULL DEFAULT FALSE, + ALTER hidden TYPE INT USING CASE WHEN hidden THEN 1 ELSE 0 END; + + ALTER TABLE handmade_project RENAME hidden TO flags; + `) + if err != nil { + return oops.New(err, "failed to revert personal project changes") + } + + _, 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 { + return oops.New(err, "failed to drop tags table") + } + + return nil +} diff --git a/src/migration/migrations/migrations.go b/src/migration/migrations/migrations.go index ca33045e..0805b909 100644 --- a/src/migration/migrations/migrations.go +++ b/src/migration/migrations/migrations.go @@ -8,7 +8,7 @@ import ( "github.com/jackc/pgx/v4" ) -var All map[types.MigrationVersion]types.Migration = make(map[types.MigrationVersion]types.Migration) +var All = make(map[types.MigrationVersion]types.Migration) func registerMigration(m types.Migration) { All[m.Version()] = m diff --git a/src/models/project.go b/src/models/project.go index 5900312a..62824928 100644 --- a/src/models/project.go +++ b/src/models/project.go @@ -2,6 +2,8 @@ package models import ( "reflect" + "regexp" + "strings" "time" ) @@ -15,7 +17,7 @@ var ProjectType = reflect.TypeOf(Project{}) type ProjectLifecycle int const ( - ProjectLifecycleUnapproved = iota + ProjectLifecycleUnapproved ProjectLifecycle = iota ProjectLifecycleApprovalRequired ProjectLifecycleActive ProjectLifecycleHiatus @@ -41,6 +43,7 @@ type Project struct { Slug string `db:"slug"` Name string `db:"name"` + TagID *int `db:"tag"` Blurb string `db:"blurb"` Description string `db:"description"` ParsedDescription string `db:"descparsed"` @@ -53,7 +56,8 @@ type Project struct { LogoLight string `db:"logolight"` LogoDark string `db:"logodark"` - Flags int `db:"flags"` // NOTE(asaf): Flags is currently only used to mark a project as hidden. Flags == 1 means hidden. Flags == 0 means visible. + Personal bool `db:"personal"` + Hidden bool `db:"hidden"` Featured bool `db:"featured"` DateApproved time.Time `db:"date_approved"` AllLastUpdated time.Time `db:"all_last_updated"` @@ -63,7 +67,7 @@ type Project struct { ForumEnabled bool `db:"forum_enabled"` BlogEnabled bool `db:"blog_enabled"` - LibraryEnabled bool `db:"library_enabled"` + LibraryEnabled bool `db:"library_enabled"` // TODO: Delete this field from the db } func (p *Project) IsHMN() bool { @@ -77,3 +81,30 @@ func (p *Project) Subdomain() string { return p.Slug } + +// Checks whether the project has forums enabled. This should restrict the creation of new forum +// content, but it should NOT prevent the viewing of existing forum content. (Projects may at one +// point have forums enabled, write some stuff, and then later disable forums, and we want that +// content to stay accessible.) Hiding the navigation is ok. +func (p *Project) HasForums() bool { + return !p.Personal && p.ForumEnabled +} + +// Same as HasForums, but for blogs. +func (p *Project) HasBlog() bool { + return !p.Personal && p.BlogEnabled +} + +var slugUnsafeChars = regexp.MustCompile(`[^a-zA-Z0-9-]`) +var slugHyphenRun = regexp.MustCompile(`-+`) + +// Generates a URL-safe version of a personal project's name. +func GeneratePersonalProjectSlug(name string) string { + slug := name + slug = slugUnsafeChars.ReplaceAllLiteralString(slug, "-") + slug = slugHyphenRun.ReplaceAllLiteralString(slug, "-") + slug = strings.Trim(slug, "-") + slug = strings.ToLower(slug) + + return slug +} diff --git a/src/models/project_test.go b/src/models/project_test.go new file mode 100644 index 00000000..13ed2681 --- /dev/null +++ b/src/models/project_test.go @@ -0,0 +1,18 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGenerateSlug(t *testing.T) { + assert.Equal(t, "godspeed-you-black-emperor", GeneratePersonalProjectSlug("Godspeed You! Black Emperor")) + assert.Equal(t, "", GeneratePersonalProjectSlug("!@#$%^&")) + assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug("-- Foo Bar --")) + assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug("--foo-bar")) + assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug("foo--bar")) + assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug("foo-bar--")) + assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug(" Foo Bar ")) + assert.Equal(t, "20-000-leagues-under-the-sea", GeneratePersonalProjectSlug("20,000 Leagues Under the Sea")) +} 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 460bcad2..0c402a04 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -59,22 +59,11 @@ var LifecycleBadgeStrings = map[models.ProjectLifecycle]string{ models.ProjectLifecycleLTS: "Complete", } -func ProjectUrl(p *models.Project) string { - var url string - if p.Lifecycle == models.ProjectLifecycleUnapproved || p.Lifecycle == models.ProjectLifecycleApprovalRequired { - url = hmnurl.BuildProjectNotApproved(p.Slug) - } else { - url = hmnurl.BuildProjectHomepage(p.Slug) - } - return url -} - -func ProjectToTemplate(p *models.Project, theme string) Project { +func ProjectToTemplate(p *models.Project, url string, theme string) Project { logo := p.LogoLight if theme == "dark" { logo = p.LogoDark } - url := ProjectUrl(p) return Project{ Name: p.Name, Subdomain: p.Subdomain(), @@ -91,9 +80,8 @@ func ProjectToTemplate(p *models.Project, theme string) Project { IsHMN: p.IsHMN(), - HasBlog: p.BlogEnabled, - HasForum: p.ForumEnabled, - HasLibrary: false, // TODO: port the library lol + HasBlog: p.HasBlog(), + HasForum: p.HasForums(), DateApproved: p.DateApproved, } @@ -319,7 +307,23 @@ func TimelineItemsToJSON(items []TimelineItem) string { builder.WriteString(`"discord_message_url":"`) builder.WriteString(item.DiscordMessageUrl) - builder.WriteString(`"`) + builder.WriteString(`",`) + + builder.WriteString(`"tags":[`) + for _, tag := range item.Tags { + builder.WriteString(`{`) + + builder.WriteString(`"text":"`) + builder.WriteString(tag.Text) + builder.WriteString(`",`) + + builder.WriteString(`"url":"`) + builder.WriteString(tag.Url) + builder.WriteString(`"`) + + builder.WriteString(`}`) + } + builder.WriteString(`]`) builder.WriteRune('}') } @@ -380,6 +384,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/blog_post.html b/src/templates/src/blog_post.html index b984dfe3..fd7444db 100644 --- a/src/templates/src/blog_post.html +++ b/src/templates/src/blog_post.html @@ -20,7 +20,7 @@
- {{ if $.User }} + {{ if and $.User $.Project.HasBlog }}
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}   @@ -81,7 +81,7 @@
- {{ if $.User }} + {{ if and $.User $.Project.HasBlog }}
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}   @@ -111,10 +111,12 @@
- {{ if $.User }} - + Add Comment - {{ else }} - Log in to comment + {{ if .Project.HasBlog }} + {{ if $.User }} + + Add Comment + {{ else }} + Log in to comment + {{ end }} {{ end }}
diff --git a/src/templates/src/forum.html b/src/templates/src/forum.html index 99f35632..39b36b4b 100644 --- a/src/templates/src/forum.html +++ b/src/templates/src/forum.html @@ -34,14 +34,18 @@ {{ define "subforum_options" }}
+ {{ if .Project.HasForum }} + {{ if .User }} + + New Thread + {{ else }} + Log in to post a new thread + {{ end }} + {{ end }} {{ if .User }} - + New Thread
{{ csrftoken .Session }}
- {{ else }} - Log in to post a new thread {{ end }}
diff --git a/src/templates/src/forum_thread.html b/src/templates/src/forum_thread.html index 74aa4c8f..1af99fb7 100644 --- a/src/templates/src/forum_thread.html +++ b/src/templates/src/forum_thread.html @@ -61,7 +61,7 @@ - {{ if $.User }} + {{ if and $.User $.Project.HasForum }}
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}   @@ -120,10 +120,12 @@ ← Back to index {{ if .Thread.Locked }} Thread is locked. - {{ else if .User }} - ⤷ Reply to Thread - {{ else }} - Log in to reply + {{ else if .Project.HasForum }} + {{ if .User }} + ⤷ Reply to Thread + {{ else }} + Log in to reply + {{ end }} {{ end }}
diff --git a/src/templates/src/include/showcase_templates.html b/src/templates/src/include/showcase_templates.html index 3affda67..c640a927 100644 --- a/src/templates/src/include/showcase_templates.html +++ b/src/templates/src/include/showcase_templates.html @@ -30,6 +30,7 @@
Unknown description
+
@@ -38,3 +39,7 @@
+ + \ No newline at end of file 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/src/project_index.html b/src/templates/src/project_index.html index 69d7f027..6bd1e2a4 100644 --- a/src/templates/src/project_index.html +++ b/src/templates/src/project_index.html @@ -1,37 +1,28 @@ {{ template "base.html" . }} {{ define "content" }} -
+
{{ with .CarouselProjects }} -