diff --git a/_embed/templates/admin-searchTags/index.html b/_embed/templates/admin-searchTags/index.html index 85b9852c..233b0d81 100644 --- a/_embed/templates/admin-searchTags/index.html +++ b/_embed/templates/admin-searchTags/index.html @@ -1,32 +1,28 @@ -{{- $parent := .QueryParam "parent" -}} {{- $name := .QueryParam "name" -}} {{- $stateID := .QueryParam "stateId" -}} +{{- $groupID := .QueryParam "group" -}} -
+
{{template "menubar" .}} -
+
- {{- if ne "" $parent -}} -
- - {{$parent}}   {{icon "delete"}} -
- {{- end -}}
+
+ + +
- - - - -
diff --git a/_embed/templates/admin-searchTags/index.zhtml b/_embed/templates/admin-searchTags/index.zhtml deleted file mode 100644 index d3f3e9a5..00000000 --- a/_embed/templates/admin-searchTags/index.zhtml +++ /dev/null @@ -1,72 +0,0 @@ -{{- $registration := .Registration -}} -{{- $groupID := .QueryParam "groupId" -}} -{{- $search := .QueryParam "search" -}} - -
- - - - - -
-
-
New User Signups
-
- {{ if $registration.IsZero -}} - {{icon "none" }} Not Allowed - {{- else -}} - {{- icon $registration.Icon}} {{ $registration.Label -}} - {{- end}} -
- -
- -
-
-
Total
-
{{.CountUsers}}
-
-
-
Public
-
{{.CountPublicUsers}}
-
-
-
Indexable
-
{{.CountIndexableUsers}}
-
-
-
-
- -
-
-
- -
- -
-
- - -
-
- - -
-
- - - - {{- .View "list" -}} -
- -
- -
diff --git a/_embed/templates/admin-searchTags/list.html b/_embed/templates/admin-searchTags/list.html index 2273d232..46e80beb 100644 --- a/_embed/templates/admin-searchTags/list.html +++ b/_embed/templates/admin-searchTags/list.html @@ -1,16 +1,26 @@ -{{- $tags := .SearchTags.Top60.ByName.Slice -}} +{{- $builder := . -}} +{{- $groupID := .QueryParam "group" -}} +{{- $allowSort := ne "" $groupID -}} +{{- $tags := .SearchTags.Top600.ByName.Slice -}} {{- if not $tags.IsEmpty }} {{- range $index, $tag := $tags -}} - - {{icon "tag"}} {{$tag.Name}} - - - {{- $tag.StatusText -}} + {{- if $allowSort -}} + + {{icon "drag-handle"}} + + {{- end -}} + + + {{- if not $allowSort -}} + {{icon "tag"}} + {{- end }} + {{$tag.Name}} - + + {{$tag.StatusText}} {{- end -}} diff --git a/_embed/templates/admin-searchTags/status-widget.html b/_embed/templates/admin-searchTags/status-widget.html new file mode 100644 index 00000000..41cb1a7c --- /dev/null +++ b/_embed/templates/admin-searchTags/status-widget.html @@ -0,0 +1,13 @@ +{{- $builder := index . 0 -}} +{{- $tag := index . 1 -}} + +
+ {{$stateID := string $tag.StateID}} + {{- range $index, $lookupCode := $builder.States -}} + {{- if eq $lookupCode.Value $stateID -}} + + {{- else -}} + + {{- end -}} + {{- end -}} +
\ No newline at end of file diff --git a/_embed/templates/admin-searchTags/template.hjson b/_embed/templates/admin-searchTags/template.hjson index 3f9bdd19..9ebdd362 100644 --- a/_embed/templates/admin-searchTags/template.hjson +++ b/_embed/templates/admin-searchTags/template.hjson @@ -27,17 +27,22 @@ label:General children: [ {type:"text", label:"Tag Name", path:"name", description:"Case insensitive. Don't include # symbol"} + {type:"text", label:"Group", path:"group", options:{provider:"searchTag-groups"}} {type:"select", label:"Status", path:"stateId", options:{provider: "searchTag-states"}} - {type:"text", label:"Rank", path:"rank", options:{style:"width:6em;"}} - {type:"toggle", path:"isFeatured", options:{true-text:"Featured. Show on search directory.", false-text:"Featured?"}} + {type:"text", label:"Sort/Rank", path:"rank", options:{style:"width:6em;"}} + ] + } + { + type:layout-vertical + label:Related + children: [ + {type:"textarea", path:"related", description:"Enter related #Hashtags separated by spaces", options:{rows:6}} ] } { type:layout-vertical label:Custom Banner children: [ - {type:"toggle", path:"isCustomBanner", options:{true-text:"Custom Banner. Display a custom banner when searching this tag.", false-text:"Custom Banner?"}} - {type:"textarea", label:"Related Tags", path:"related", description:"Enter #Hashtags separated by spaces"} {type:"colorpicker", label:"Background (Left)", path:"colors.0"} {type:"colorpicker", label:"Background (Right)", path:"colors.1"} ] @@ -48,8 +53,7 @@ children: [ {type:"textarea", path:"notes", description:"Notes are only visible by administrators", options:{rows:8}} ] - } - ] + } ] } } {do:"save"} @@ -75,19 +79,25 @@ label:General children: [ {type:"text", label:"Tag Name", path:"name", description:"Case insensitive. Don't include # symbol"} + {type:"text", label:"Group", path:"group", options:{provider:"searchTag-groups"}} {type:"select", label:"Status", path:"stateId", options:{provider: "searchTag-states"}} - {type:"text", label:"Rank", path:"rank", options:{style:"width:6em;"}} - {type:"toggle", path:"isFeatured", options:{true-text:"Featured. Show on search directory.", false-text:"Featured?"}} ] } { type:layout-vertical - label:Custom Banner + label:Display children: [ - {type:"toggle", path:"isCustomBanner", options:{true-text:"Custom Banner. Display a custom banner when searching this tag.", false-text:"Custom Banner?"}} - {type:"textarea", label:"Related Tags", path:"related", description:"Enter #Hashtags separated by spaces"} {type:"colorpicker", label:"Background (Left)", path:"colors.0"} {type:"colorpicker", label:"Background (Right)", path:"colors.1"} + {type:"upload", label:"Image", path:"imageUrl", options:{accept:"image/*", delete:"/admin/tag/{{.SearchTagID}}/delete-image"}} + {type:"text", label:"Sort/Rank", path:"rank", options:{style:"width:6em;"}} + ] + } + { + type:layout-vertical + label:Related + children: [ + {type:"textarea", path:"related", description:"Enter related #Hashtags separated by spaces", options:{rows:8}} ] } { @@ -101,6 +111,7 @@ } options:["delete:/admin/tags/{{.SearchTagID}}/delete"] }, + {do:"upload-attachments", category:"image", fieldname:"imageUrl", attachment-path:"imageId", accept-type:"image/*", maximum:1, rules:{width:800, height:600}} {do:"save"} {do:"refresh-page"} ] @@ -114,5 +125,15 @@ {do:"refresh-page"} ] } + + delete-image: { + roles:["self"] + steps:[ + {do:"delete-attachments", field:"iconId"} + {do:"save", comment:"Deleted Image"} + {do:"reload-page"} + ] + } + } } diff --git a/_embed/templates/theme-global/stylesheet/05-positioning.css b/_embed/templates/theme-global/stylesheet/05-positioning.css index 1fca4d89..2009010f 100644 --- a/_embed/templates/theme-global/stylesheet/05-positioning.css +++ b/_embed/templates/theme-global/stylesheet/05-positioning.css @@ -22,6 +22,14 @@ position:sticky; } +.pos-absolute-top { + -webkit-position:absolute; + position:absolute; + top:var(--rhythm); + left:var(--rhythm); + right: var(--rhythm); +} + .pos-absolute-top-left { -webkit-position:absolute; position:absolute; @@ -36,6 +44,14 @@ right:var(--rhythm); } +.pos-absolute-bottom { + -webkit-position:absolute; + position:absolute; + bottom:var(--rhythm); + left:var(--rhythm); + right: var(--rhythm); +} + .pos-absolute-bottom-left { -webkit-position:absolute; position:absolute; diff --git a/build/builder_admin_tags.go b/build/builder_admin_tags.go index 62cf7d91..8e000341 100644 --- a/build/builder_admin_tags.go +++ b/build/builder_admin_tags.go @@ -11,6 +11,7 @@ import ( "github.com/benpate/derp" "github.com/benpate/exp" builder "github.com/benpate/exp-builder" + "github.com/benpate/form" "github.com/benpate/rosetta/schema" "github.com/rs/zerolog/log" "go.mongodb.org/mongo-driver/bson/primitive" @@ -155,6 +156,7 @@ func (w SearchTag) SearchTags() *QueryBuilder[model.SearchTag] { query := builder.NewBuilder(). String("name", builder.WithDefaultOpBeginsWith()). + String("group"). Int("stateId") criteria := exp.And( @@ -168,6 +170,14 @@ func (w SearchTag) SearchTags() *QueryBuilder[model.SearchTag] { return &result } +func (w SearchTag) States() []form.LookupCode { + return w.lookupProvider().Group("searchTag-states").Get() +} + +func (w SearchTag) Groups() []form.LookupCode { + return w._factory.SearchTag().ListGroups() +} + func (w SearchTag) debug() { log.Debug().Interface("object", w.object()).Msg("builder_admin_searchTag") } diff --git a/build/builder_common.go b/build/builder_common.go index 0f6fba76..ae25d470 100644 --- a/build/builder_common.go +++ b/build/builder_common.go @@ -537,8 +537,7 @@ func (w Common) SearchTag(name string) model.SearchTag { func (w Common) FeaturedSearchTags() *QueryBuilder[model.SearchTag] { criteria := exp.And( - exp.Equal("stateId", model.SearchTagStateAllowed), - exp.Equal("isFeatured", true), + exp.Equal("stateId", model.SearchTagStateFeatured), exp.Equal("deleteDate", 0), ) diff --git a/build/step_IfCondition.go b/build/step_IfCondition.go index fc402974..77b3a783 100644 --- a/build/step_IfCondition.go +++ b/build/step_IfCondition.go @@ -1,13 +1,13 @@ package build import ( - "bytes" "html/template" "io" "strings" "github.com/EmissarySocial/emissary/model/step" "github.com/benpate/derp" + "github.com/benpate/rosetta/convert" ) // StepIfCondition is a Step that can update the data.DataMap custom data stored in a Stream @@ -47,12 +47,7 @@ func (step StepIfCondition) execute(builder Builder, buffer io.Writer, method Ac // evaluateCondition executes the conditional template and func (step StepIfCondition) evaluateCondition(builder Builder) bool { - - var result bytes.Buffer - - if err := step.Condition.Execute(&result, builder); err != nil { - return false - } - - return (strings.TrimSpace(result.String()) == "true") + condition := executeTemplate(step.Condition, builder) + condition = strings.TrimSpace(condition) + return convert.Bool(condition) } diff --git a/domain/factory.go b/domain/factory.go index 5d4471bb..db821e21 100644 --- a/domain/factory.go +++ b/domain/factory.go @@ -773,7 +773,16 @@ func (factory *Factory) Steranko() *steranko.Steranko { // LookupProvider returns a fully populated LookupProvider service func (factory *Factory) LookupProvider(userID primitive.ObjectID) form.LookupProvider { - return service.NewLookupProvider(factory.Domain(), factory.Folder(), factory.Group(), factory.Registration(), factory.Template(), factory.Theme(), userID) + return service.NewLookupProvider( + factory.Domain(), + factory.Folder(), + factory.Group(), + factory.Registration(), + factory.SearchTag(), + factory.Template(), + factory.Theme(), + userID, + ) } /****************************************** diff --git a/handler/attachment.go b/handler/attachment.go index aced24a1..bf85b762 100644 --- a/handler/attachment.go +++ b/handler/attachment.go @@ -4,17 +4,19 @@ import ( "net/http" "github.com/EmissarySocial/emissary/build" + "github.com/EmissarySocial/emissary/domain" "github.com/EmissarySocial/emissary/model" "github.com/EmissarySocial/emissary/server" "github.com/benpate/derp" "github.com/benpate/rosetta/list" + "github.com/benpate/steranko" "github.com/labstack/echo/v4" "go.mongodb.org/mongo-driver/bson/primitive" ) -func GetStreamAttachment(factoryManager *server.Factory) echo.HandlerFunc { +func GetDomainAttachment(factoryManager *server.Factory) echo.HandlerFunc { - const location = "handler.GetAttachment" + const location = "handler.GetDomainAttachment" return func(ctx echo.Context) error { @@ -30,12 +32,7 @@ func GetStreamAttachment(factoryManager *server.Factory) echo.HandlerFunc { return derp.Wrap(err, location, "Cannot load Domain") } - // Get StreamID from the request - streamID, err := primitive.ObjectIDFromHex(ctx.Param("stream")) - - if err != nil { - return derp.Wrap(err, location, "Invalid streamID", ctx.Param("stream")) - } + domain := factory.Domain().Get() // Load the attachment in order to verify that it is valid for this stream // TODO: LOW: This might be more efficient as a single query... @@ -47,24 +44,11 @@ func GetStreamAttachment(factoryManager *server.Factory) echo.HandlerFunc { return derp.Wrap(err, location, "Invalid attachmentID", attachmentIDString) } - attachment := model.NewAttachment(model.AttachmentObjectTypeStream, streamID) - if err := attachmentService.LoadByID(model.AttachmentObjectTypeStream, streamID, attachmentID, &attachment); err != nil { + attachment := model.NewAttachment(model.AttachmentObjectTypeDomain, domain.DomainID) + if err := attachmentService.LoadByID(model.AttachmentObjectTypeDomain, domain.DomainID, attachmentID, &attachment); err != nil { return derp.Wrap(err, location, "Error loading attachment") } - // Load Stream (to verify permissions?) - var stream model.Stream - streamService := factory.Stream() - - if err := streamService.LoadByID(streamID, &stream); err != nil { - return derp.Wrap(err, location, "Error loading Stream", streamID) - } - - // Try to find the action requested by the user. This also enforces user permissions... - if _, err := build.NewStreamWithoutTemplate(factory, ctx.Request(), ctx.Response(), &stream, "view"); err != nil { - return derp.ReportAndReturn(derp.Wrap(err, location, "Cannot create builder", &stream, &attachment)) - } - // Retrieve the file from the mediaserver ms := factory.MediaServer() filespec := attachment.FileSpec(ctx.Request().URL) @@ -72,12 +56,7 @@ func GetStreamAttachment(factoryManager *server.Factory) echo.HandlerFunc { header := ctx.Response().Header() header.Set("Content-Type", filespec.MimeType()) header.Set("ETag", "1") - - if stream.DefaultAllowAnonymous() { - header.Set("Cache-Control", "public, max-age=86400") // Store in public caches for 1 day - } else { - header.Set("Cache-Control", "private") // Store only in private caches for 1 day - } + header.Set("Cache-Control", "public, max-age=86400") // Store in public caches for 1 day if err := ms.Get(filespec, ctx.Response().Writer); err != nil { return derp.ReportAndReturn(derp.Wrap(err, location, "Error accessing attachment file")) @@ -87,9 +66,57 @@ func GetStreamAttachment(factoryManager *server.Factory) echo.HandlerFunc { } } -func GetDomainAttachment(factoryManager *server.Factory) echo.HandlerFunc { +func GetSearchTagAttachment(ctx *steranko.Context, factory *domain.Factory) error { - const location = "handler.GetDomainAttachment" + const location = "handler.GetSearchTagAttachment" + + // Check ETags to see if the browser already has a copy of this + if matchHeader := ctx.Request().Header.Get("If-None-Match"); matchHeader == "1" { + return ctx.NoContent(http.StatusNotModified) + } + + attachmentService := factory.Attachment() + + // Locate the SearchTagID + searchTagID, err := primitive.ObjectIDFromHex(ctx.Param("searchTagId")) + + if err != nil { + return derp.Wrap(err, location, "Invalid SearchTagID") + } + + // Locate the AttachmentID + attachmentID, err := primitive.ObjectIDFromHex(ctx.Param("attachmentId")) + + if err != nil { + return derp.Wrap(err, location, "Invalid AttachmentID") + } + + // Load the Attachment record from the database + attachment := model.NewAttachment(model.AttachmentObjectTypeSearchTag, searchTagID) + + if err := attachmentService.LoadByID(model.AttachmentObjectTypeSearchTag, searchTagID, attachmentID, &attachment); err != nil { + return derp.Wrap(err, location, "Error loading attachment") + } + + // Retrieve the file from the mediaserver + ms := factory.MediaServer() + filespec := attachment.FileSpec(ctx.Request().URL) + + header := ctx.Response().Header() + header.Set("Content-Type", filespec.MimeType()) + header.Set("ETag", "1") + header.Set("Cache-Control", "public, max-age=86400") // Store in public caches for 1 day + + if err := ms.Get(filespec, ctx.Response().Writer); err != nil { + return derp.ReportAndReturn(derp.Wrap(err, location, "Error accessing attachment file")) + } + + return nil +} + +func GetStreamAttachment(factoryManager *server.Factory) echo.HandlerFunc { + + const location = "handler.GetAttachment" return func(ctx echo.Context) error { @@ -105,7 +132,12 @@ func GetDomainAttachment(factoryManager *server.Factory) echo.HandlerFunc { return derp.Wrap(err, location, "Cannot load Domain") } - domain := factory.Domain().Get() + // Get StreamID from the request + streamID, err := primitive.ObjectIDFromHex(ctx.Param("stream")) + + if err != nil { + return derp.Wrap(err, location, "Invalid streamID", ctx.Param("stream")) + } // Load the attachment in order to verify that it is valid for this stream // TODO: LOW: This might be more efficient as a single query... @@ -117,11 +149,24 @@ func GetDomainAttachment(factoryManager *server.Factory) echo.HandlerFunc { return derp.Wrap(err, location, "Invalid attachmentID", attachmentIDString) } - attachment := model.NewAttachment(model.AttachmentObjectTypeDomain, domain.DomainID) - if err := attachmentService.LoadByID(model.AttachmentObjectTypeDomain, domain.DomainID, attachmentID, &attachment); err != nil { + attachment := model.NewAttachment(model.AttachmentObjectTypeStream, streamID) + if err := attachmentService.LoadByID(model.AttachmentObjectTypeStream, streamID, attachmentID, &attachment); err != nil { return derp.Wrap(err, location, "Error loading attachment") } + // Load Stream (to verify permissions?) + var stream model.Stream + streamService := factory.Stream() + + if err := streamService.LoadByID(streamID, &stream); err != nil { + return derp.Wrap(err, location, "Error loading Stream", streamID) + } + + // Try to find the action requested by the user. This also enforces user permissions... + if _, err := build.NewStreamWithoutTemplate(factory, ctx.Request(), ctx.Response(), &stream, "view"); err != nil { + return derp.ReportAndReturn(derp.Wrap(err, location, "Cannot create builder", &stream, &attachment)) + } + // Retrieve the file from the mediaserver ms := factory.MediaServer() filespec := attachment.FileSpec(ctx.Request().URL) @@ -129,7 +174,12 @@ func GetDomainAttachment(factoryManager *server.Factory) echo.HandlerFunc { header := ctx.Response().Header() header.Set("Content-Type", filespec.MimeType()) header.Set("ETag", "1") - header.Set("Cache-Control", "public, max-age=86400") // Store in public caches for 1 day + + if stream.DefaultAllowAnonymous() { + header.Set("Cache-Control", "public, max-age=86400") // Store in public caches for 1 day + } else { + header.Set("Cache-Control", "private") // Store only in private caches for 1 day + } if err := ms.Get(filespec, ctx.Response().Writer); err != nil { return derp.ReportAndReturn(derp.Wrap(err, location, "Error accessing attachment file")) diff --git a/model/attachment_accessors.go b/model/attachment_accessors.go index 6909540c..3455541b 100644 --- a/model/attachment_accessors.go +++ b/model/attachment_accessors.go @@ -11,7 +11,7 @@ func AttachmentSchema() schema.Element { Properties: schema.ElementMap{ "attachmentId": schema.String{Format: "objectId"}, "objectId": schema.String{Format: "objectId"}, - "objectType": schema.String{Enum: []string{AttachmentObjectTypeDomain, AttachmentObjectTypeStream, AttachmentObjectTypeUser}}, + "objectType": schema.String{Enum: []string{AttachmentObjectTypeDomain, AttachmentObjectTypeSearchTag, AttachmentObjectTypeStream, AttachmentObjectTypeUser}}, "category": schema.String{}, "label": schema.String{}, "description": schema.String{}, diff --git a/model/attachment_constants.go b/model/attachment_constants.go index 2d4ecba2..93d87cd2 100644 --- a/model/attachment_constants.go +++ b/model/attachment_constants.go @@ -3,6 +3,9 @@ package model // AttachmentObjectTypeDomain represents an attachment that is owned by a Domain const AttachmentObjectTypeDomain = "Domain" +// AttachmentObjectTypeSearchTag represents an attachment that is owned by a SearchTag +const AttachmentObjectTypeSearchTag = "SearchTag" + // AttachmentObjectTypeStream represents an attachment that is owned by a Stream const AttachmentObjectTypeStream = "Stream" diff --git a/model/searchTag.go b/model/searchTag.go index 26e0a24d..db90eb99 100644 --- a/model/searchTag.go +++ b/model/searchTag.go @@ -10,16 +10,15 @@ import ( // SearchTag represents a tag that Users and Guests can use to search // for streams in the database. type SearchTag struct { - SearchTagID primitive.ObjectID `bson:"_id"` // SearchTagID is the unique identifier for a SearchTag. - Name string `bson:"name"` // Name used for this tag - Description string `bson:"description"` // Description is shown on tags featured in search panels - Colors sliceof.String `bson:"colors"` // Colors is a slice of one or more RGB Hex color to use for tags featured on search panels. - Notes string `bson:"notes"` // Notes is a place for administrators to make notes about the tag. - Related string `bson:"related"` // Related is a list of other tags that are related to this tag. - Rank int `bson:"rank"` // Rank is the sort order of the SearchTag. - StateID int `bson:"stateId"` // StateID represents the state that the tag is in. (FEATURED, ALLOWED, WAITING, BLOCKED) - IsFeatured bool `bson:"isFeatured"` // IsFeatured is TRUE if this tag is featured on home page search panels. - IsCustomBanner bool `bson:"isCustomBanner"` // IsCustomBanner is TRUE if this tag has a custom banner for this tag. + SearchTagID primitive.ObjectID `bson:"_id"` // SearchTagID is the unique identifier for a SearchTag. + Group string `bson:"group"` // Group is the type of tag (GENRE, MOOD, ACTIVITY, etc.) + Name string `bson:"name"` // Name used for this tag + Colors sliceof.String `bson:"colors"` // Colors is a slice of one or more RGB Hex color to use for tags featured on search panels. + Related string `bson:"related"` // Related is a list of other tags that are related to this tag. + Notes string `bson:"notes"` // Notes is a place for administrators to make notes about the tag. + Rank int `bson:"rank"` // Rank is the sort order of the SearchTag. + StateID int `bson:"stateId"` // StateID represents the state that the tag is in. (FEATURED, ALLOWED, WAITING, BLOCKED) + ImageID primitive.ObjectID `bson:"imageId"` // AttachmentID is the unique identifier for the attachment that is associated with this tag. journal.Journal `bson:",inline"` } @@ -47,6 +46,8 @@ func (searchTag SearchTag) StatusText() string { return "Waiting" case SearchTagStateAllowed: return "Allowed" + case SearchTagStateFeatured: + return "Featured" default: return "Unknown" } @@ -56,12 +57,22 @@ func (searchTag SearchTag) Fields() []string { return []string{ "_id", "name", - "stateId", "colors", - "description", + "stateId", + "imageId", + "related", } } func (searchTag SearchTag) RelatedTags() sliceof.String { return parse.Hashtags(searchTag.Related) } + +func (searchTag SearchTag) ImageURL() string { + + if searchTag.ImageID.IsZero() { + return "" + } + + return "/.searchTag/" + searchTag.SearchTagID.Hex() + "/attachments/" + searchTag.ImageID.Hex() +} diff --git a/model/searchTag_accessors.go b/model/searchTag_accessors.go index a0bf961d..a7623ad0 100644 --- a/model/searchTag_accessors.go +++ b/model/searchTag_accessors.go @@ -9,16 +9,16 @@ import ( func SearchTagSchema() schema.Element { return schema.Object{ Properties: schema.ElementMap{ - "searchTagId": schema.String{Format: "objectId"}, - "name": schema.String{Required: true}, - "description": schema.String{}, - "colors": schema.Array{Items: schema.String{Format: "color"}}, - "notes": schema.String{}, - "related": schema.String{}, - "rank": schema.Integer{}, - "stateId": schema.Integer{Enum: []int{SearchTagStateAllowed, SearchTagStateWaiting, SearchTagStateBlocked}}, - "isFeatured": schema.Boolean{}, - "isCustomBanner": schema.Boolean{}, + "searchTagId": schema.String{Format: "objectId"}, + "group": schema.String{}, + "name": schema.String{Required: true}, + "stateId": schema.Integer{Enum: []int{SearchTagStateFeatured, SearchTagStateAllowed, SearchTagStateWaiting, SearchTagStateBlocked}}, + "related": schema.String{}, + "rank": schema.Integer{}, + "colors": schema.Array{Items: schema.String{Format: "color"}}, + "notes": schema.String{}, + "imageId": schema.String{Format: "objectId"}, + "imageUrl": schema.String{Format: "url"}, }, } } @@ -29,32 +29,27 @@ func (searchTag *SearchTag) GetPointer(name string) (any, bool) { switch name { + case "group": + return &searchTag.Group, true + case "name": return &searchTag.Name, true - case "description": - return &searchTag.Description, true - - case "colors": - return &searchTag.Colors, true - - case "notes": - return &searchTag.Notes, true + case "stateId": + return &searchTag.StateID, true case "related": return &searchTag.Related, true - case "stateId": - return &searchTag.StateID, true + case "rank": + return &searchTag.Rank, true - case "isFeatured": - return &searchTag.IsFeatured, true + case "colors": + return &searchTag.Colors, true - case "isCustomBanner": - return &searchTag.IsCustomBanner, true + case "notes": + return &searchTag.Notes, true - case "rank": - return &searchTag.Rank, true } return nil, false @@ -68,6 +63,12 @@ func (searchTag SearchTag) GetStringOK(name string) (string, bool) { case "searchTagId": return searchTag.SearchTagID.Hex(), true + + case "imageId": + return searchTag.ImageID.Hex(), true + + case "imageUrl": + return searchTag.ImageURL(), true } return "", false @@ -84,6 +85,16 @@ func (searchTag *SearchTag) SetString(name string, value string) bool { searchTag.SearchTagID = objectID return true } + + case "imageId": + if objectID, err := primitive.ObjectIDFromHex(value); err == nil { + searchTag.ImageID = objectID + return true + } + + // Fail silently when "setting" this virtual field + case "imageUrl": + return true } return false diff --git a/model/searchTag_constants.go b/model/searchTag_constants.go index 8bece301..c307b2ec 100644 --- a/model/searchTag_constants.go +++ b/model/searchTag_constants.go @@ -1,5 +1,8 @@ package model +// SearchTagStateFeatured represents a SearchTag that is featured on the search index +const SearchTagStateFeatured = 2 + // SearchTagStateAllowed represents a SearchTag that is allowed on the search index const SearchTagStateAllowed = 1 diff --git a/model/searchTag_test.go b/model/searchTag_test.go index 332071ec..7e95636d 100644 --- a/model/searchTag_test.go +++ b/model/searchTag_test.go @@ -13,6 +13,7 @@ func TestSearchTag(t *testing.T) { tests := []tableTestItem{ {"searchTagId", "000000000000000000000001", nil}, + {"type", "GENRE", nil}, {"name", "MYTAG", nil}, {"description", "DESCRIPTION", nil}, {"colors.01", "#663399", nil}, diff --git a/queries/searchTags.go b/queries/searchTags.go index f1c8d8cf..e52f2805 100644 --- a/queries/searchTags.go +++ b/queries/searchTags.go @@ -7,6 +7,7 @@ import ( "github.com/EmissarySocial/emissary/model" "github.com/benpate/data" "github.com/benpate/derp" + "github.com/benpate/rosetta/convert" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" @@ -51,3 +52,28 @@ func SearchTagByName(collection data.Collection, name string, result *model.Sear // Success return nil } + +func SearchTags_Groups(collection data.Collection) ([]string, error) { + + const location = "queries.SearchTags_Groups" + + // Get a Mongo collection + m := mongoCollection(collection) + + if m == nil { + return nil, derp.NewInternalError(location, "Invalid collection") + } + + // Context + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + + criteria := bson.M{"group": bson.M{"$ne": ""}} + sliceOfInterface, err := m.Distinct(ctx, "group", criteria) + + if err != nil { + return nil, derp.Wrap(err, location, "Error reading distinct groups") + } + + return convert.SliceOfString(sliceOfInterface), nil +} diff --git a/server.go b/server.go index ea07542a..18dc3772 100644 --- a/server.go +++ b/server.go @@ -233,6 +233,7 @@ func makeStandardRoutes(factory *server.Factory, e *echo.Echo) { e.GET("/.giphy", handler.GetGiphyWidget(factory)) e.GET("/.oembed", handler.WithFactory(factory, handler.GetOEmbed)) e.POST("/.stripe", stripe.PostWebhook(factory)) + e.GET("/.searchTag/:searchTagId/attachments/:attachmentId", handler.WithFactory(factory, handler.GetSearchTagAttachment)) e.GET("/.themes/:themeId/:bundleId", handler.GetThemeBundle(factory)) e.GET("/.themes/:themeId/resources/:filename", handler.GetThemeResource(factory)) e.GET("/.templates/:templateId/:bundleId", handler.GetTemplateBundle(factory)) diff --git a/service/lookupProvider.go b/service/lookupProvider.go index 6c5edad5..d8f306fb 100644 --- a/service/lookupProvider.go +++ b/service/lookupProvider.go @@ -13,19 +13,21 @@ type LookupProvider struct { folderService *Folder groupService *Group registrationService *Registration + searchTagService *SearchTag templateService *Template themeService *Theme userID primitive.ObjectID } -func NewLookupProvider(domainService *Domain, folderService *Folder, groupService *Group, registrationService *Registration, templateService *Template, themeService *Theme, userID primitive.ObjectID) LookupProvider { +func NewLookupProvider(domainService *Domain, folderService *Folder, groupService *Group, registrationService *Registration, searchTagService *SearchTag, templateService *Template, themeService *Theme, userID primitive.ObjectID) LookupProvider { return LookupProvider{ domainService: domainService, - themeService: themeService, - templateService: templateService, - registrationService: registrationService, - groupService: groupService, folderService: folderService, + groupService: groupService, + registrationService: registrationService, + searchTagService: searchTagService, + templateService: templateService, + themeService: themeService, userID: userID, } } @@ -94,11 +96,15 @@ func (service LookupProvider) Group(path string) form.LookupGroup { case "searchTag-states": return form.NewReadOnlyLookupGroup( - form.LookupCode{Value: "1", Label: "ALLOWED - users can search for this tag"}, - form.LookupCode{Value: "0", Label: "WAITING - has not yet been categorized."}, - form.LookupCode{Value: "-1", Label: "BLOCKED - users cannot search for this tag"}, + form.LookupCode{Value: "2", Label: "Featured", Description: "Features this tag on search pages."}, + form.LookupCode{Value: "1", Label: "Allowed", Description: "Users can search for this tag."}, + form.LookupCode{Value: "0", Label: "Waiting", Description: "Has not yet been categorized."}, + form.LookupCode{Value: "-1", Label: "Blocked", Description: "Users cannot see this tag at all."}, ) + case "searchTag-groups": + return form.ReadOnlyLookupGroup(service.searchTagService.ListGroups()) + case "sharing": return form.NewReadOnlyLookupGroup( form.LookupCode{Value: "anonymous", Label: "Everyone (including anonymous visitors)"}, diff --git a/service/search.go b/service/search.go index 576801c3..8e5e5102 100644 --- a/service/search.go +++ b/service/search.go @@ -10,6 +10,7 @@ import ( "github.com/benpate/data/option" "github.com/benpate/derp" "github.com/benpate/exp" + "github.com/davecgh/go-spew/spew" ) // Search defines a service that manages all searchable pages in a domain. @@ -134,26 +135,26 @@ func (service *Search) LoadByURL(url string, searchResult *model.SearchResult) e * Custom Methods ******************************************/ +// Upsert adds or updates a SearchResult in the database func (service *Search) Upsert(searchResult model.SearchResult) error { // First, try to load the original Search original := model.NewSearchResult() - if err := service.LoadByURL(searchResult.URL, &original); !derp.NilOrNotFound(err) { - return derp.Wrap(err, "service.Search.Upsert", "Error loading Search", searchResult) - } else if err == nil { + err := service.LoadByURL(searchResult.URL, &original) + + spew.Dump(err) + + if err == nil { original.Update(searchResult) - } else { + } else if derp.NotFound(err) { original = searchResult + } else { + return derp.Wrap(err, "service.Search.Upsert", "Error loading Search", searchResult) } - // Update the original Search with the new values - original.Update(searchResult) - comment := "added" - - if !original.IsNew() { - comment = "updated" - } + comment := iif(original.IsNew(), "added", "updated") + spew.Dump(original) if err := service.Save(&original, comment); err != nil { return derp.Wrap(err, "service.Search.Add", "Error adding Search", searchResult) @@ -162,6 +163,7 @@ func (service *Search) Upsert(searchResult model.SearchResult) error { return nil } +// DeleteByURL removes a SearchResult from the database that matches the provided URL func (service *Search) DeleteByURL(url string) error { searchResult := model.NewSearchResult() diff --git a/service/searchTag.go b/service/searchTag.go index c9c511a6..110017dc 100644 --- a/service/searchTag.go +++ b/service/searchTag.go @@ -7,6 +7,7 @@ import ( "github.com/benpate/data/option" "github.com/benpate/derp" "github.com/benpate/exp" + "github.com/benpate/form" "github.com/benpate/rosetta/schema" "go.mongodb.org/mongo-driver/bson/primitive" @@ -92,11 +93,6 @@ func (service *SearchTag) LoadWithOptions(criteria exp.Expression, searchTag *mo // Save adds/updates an SearchTag in the database func (service *SearchTag) Save(searchTag *model.SearchTag, note string) error { - // RULE: If the SearchTag IsFeatured, then also mark it as "Allowed" - if searchTag.IsFeatured { - searchTag.StateID = model.SearchTagStateAllowed - } - // Validate the value before saving if err := service.Schema().Validate(searchTag); err != nil { return derp.Wrap(err, "service.SearchTag.Save", "Error validating SearchTag", searchTag) @@ -224,3 +220,27 @@ func (service *SearchTag) Upsert(name string) error { // Otherwise, return the error to the caller. (This should never happen) return derp.Wrap(err, "service.SearchTag.Upsert", "Error loading SearchTag", name) } + +// ListGroups returns a distinct list of all the groups that are used by SearchTags +func (service *SearchTag) ListGroups() []form.LookupCode { + + const location = "service.SearchTag.ListGroups" + + groups, err := queries.SearchTags_Groups(service.collection) + + if err != nil { + derp.Report(derp.Wrap(err, location, "Error reading distinct groups")) + return []form.LookupCode{} + } + + result := make([]form.LookupCode, len(groups)) + + for index, group := range groups { + result[index] = form.LookupCode{ + Value: group, + Label: group, + } + } + + return result +} diff --git a/tools/templates/functions.go b/tools/templates/functions.go index 439d755d..dd92020e 100644 --- a/tools/templates/functions.go +++ b/tools/templates/functions.go @@ -363,6 +363,10 @@ func FuncMap(icons icon.Provider) template.FuncMap { return primitive.NewObjectID().Hex() }, + "string": func(value any) string { + return convert.String(value) + }, + "int": func(value string) int { return convert.Int(value) },