@@ -63,6 +74,9 @@ export default function TaggedResourcesPage() { function EditTagButton({tag, onEdited}: { tag: Tag, onEdited: (t: Tag) => void }) { const [description, setDescription] = useState(tag.description); + const [isAlias, setIsAlias] = useState(false); + const [aliasOf, setAliasOf] = useState(null); + const [type, setType] = useState(tag.type); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const { t } = useTranslation(); @@ -72,16 +86,13 @@ function EditTagButton({tag, onEdited}: { tag: Tag, onEdited: (t: Tag) => void } }, [tag.description]); const submit = async () => { - if (description === tag.description) { - return; - } if (description && description.length > 256) { setError(t("Description is too long")); return; } setIsLoading(true); setError(null); - const res = await network.setTagDescription(tag.id, description); + const res = await network.setTagInfo(tag.id, description, aliasOf?.id ?? null, type); setIsLoading(false); if (res.success) { const dialog = document.getElementById("edit_tag_dialog") as HTMLDialogElement; @@ -98,11 +109,34 @@ function EditTagButton({tag, onEdited}: { tag: Tag, onEdited: (t: Tag) => void } dialog.showModal(); }}>{t("Edit")} - + {t("Edit Tag")} - {t("Set the description of the tag.")} - {t("Use markdown format.")} - setDescription(e.target.value)}/> + + The tag is an alias of another tag + { + setIsAlias(e.target.checked); + }}/> + + + { + isAlias ? <> + { + aliasOf && + Alias Of: + {aliasOf.name} + + } + { + setAliasOf(tag); + }}/> + > : <> + setType(e.target.value)} label={"Type"}/> + setDescription(e.target.value)}/> + > + } + {error && } diff --git a/server/api/tag.go b/server/api/tag.go index 7390e55..a31c67c 100644 --- a/server/api/tag.go +++ b/server/api/tag.go @@ -36,7 +36,9 @@ func handleSearchTag(c fiber.Ctx) error { return model.NewRequestError("Keyword is required") } keyword = strings.TrimSpace(keyword) - tags, err := service.SearchTag(keyword) + mainTagStr := c.Query("main_tag") + mainTag := mainTagStr == "true" || mainTagStr == "1" + tags, err := service.SearchTag(keyword, mainTag) if tags == nil { tags = []model.TagView{} } @@ -66,7 +68,7 @@ func handleDeleteTag(c fiber.Ctx) error { }) } -func handleSetTagDescription(c fiber.Ctx) error { +func handleSetTagInfo(c fiber.Ctx) error { uid, ok := c.Locals("uid").(uint) if !ok { return model.NewUnAuthorizedError("You must be logged in to set tag description") @@ -76,11 +78,19 @@ func handleSetTagDescription(c fiber.Ctx) error { return model.NewRequestError("Invalid tag ID") } description := c.FormValue("description") - if description == "" { - return model.NewRequestError("Description is required") - } description = strings.TrimSpace(description) - t, err := service.SetTagDescription(uid, uint(id), description) + aliasOfStr := c.FormValue("alias_of") + var aliasOf *uint + if aliasOfStr != "" { + aliasID, err := strconv.Atoi(aliasOfStr) + if err != nil { + return model.NewRequestError("Invalid alias ID") + } + aliasUint := uint(aliasID) + aliasOf = &aliasUint + } + tagType := c.FormValue("type") + t, err := service.SetTagInfo(uid, uint(id), description, aliasOf, tagType) if err != nil { return err } @@ -118,7 +128,7 @@ func AddTagRoutes(api fiber.Router) { tag.Post("/", handleCreateTag) tag.Get("/search", handleSearchTag) tag.Delete("/:id", handleDeleteTag) - tag.Put("/:id/description", handleSetTagDescription) + tag.Put("/:id/info", handleSetTagInfo) tag.Get("/:name", handleGetTagByName) } } diff --git a/server/dao/resource.go b/server/dao/resource.go index bd9817c..0254702 100644 --- a/server/dao/resource.go +++ b/server/dao/resource.go @@ -180,20 +180,37 @@ func searchWithKeyword(keyword string) ([]model.Resource, error) { } if len([]rune(keyword)) < 20 { var tag model.Tag - if err := db.Where("name = ?", keyword).First(&tag).Error; err != nil { + var err error + if tag, err = GetTagByName(keyword); err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } } else { - if err := db.Model(&tag).Preload("Resources").Find(&tag).Error; err != nil { + if tag.AliasOf != nil { + tag, err = GetTagByID(*tag.AliasOf) + if err != nil { + return nil, err + } + } + var tagIds []uint + tagIds = append(tagIds, tag.ID) + for _, alias := range tag.Aliases { + tagIds = append(tagIds, alias.ID) + } + var resources []model.Resource + subQuery := db.Table("resource_tags"). + Select("resource_id"). + Where("tag_id IN ?", tagIds). + Group("resource_id") + if err := db.Where("id IN (?)", subQuery).Select("id", "title", "alternative_titles").Preload("Tags").Find(&resources).Error; err != nil { return nil, err } - return tag.Resources, nil + return resources, nil } } if len([]rune(keyword)) < 80 { var resources []model.Resource - if err := db.Where("title LIKE ?", "%"+keyword+"%").Or("alternative_titles LIKE ?", "%"+keyword+"%").Find(&resources).Error; err != nil { + if err := db.Where("title LIKE ?", "%"+keyword+"%").Or("alternative_titles LIKE ?", "%"+keyword+"%").Select("id", "title", "alternative_titles").Preload("Tags").Find(&resources).Error; err != nil { return nil, err } return resources, nil @@ -202,23 +219,53 @@ func searchWithKeyword(keyword string) ([]model.Resource, error) { } func GetResourceByTag(tagID uint, page int, pageSize int) ([]model.Resource, int, error) { - var tag model.Tag - - total := db.Model(&model.Tag{ - Model: gorm.Model{ - ID: tagID, - }, - }).Association("Resources").Count() - - if err := db.Model(&model.Tag{}).Where("id = ?", tagID).Preload("Resources", func(tx *gorm.DB) *gorm.DB { - return tx.Offset((page - 1) * pageSize).Limit(pageSize).Preload("Tags").Preload("User").Preload("Images").Order("created_at DESC") - }).First(&tag).Error; err != nil { + tag, err := GetTagByID(tagID) + if err != nil { return nil, 0, err } - totalPages := (int(total) + pageSize - 1) / pageSize + if tag.AliasOf != nil { + tag, err = GetTagByID(*tag.AliasOf) + if err != nil { + return nil, 0, err + } + } - return tag.Resources, totalPages, nil + var tagIds []uint + tagIds = append(tagIds, tag.ID) + for _, alias := range tag.Aliases { + tagIds = append(tagIds, alias.ID) + } + + var resources []model.Resource + var total int64 + + subQuery := db.Table("resource_tags"). + Select("resource_id"). + Where("tag_id IN ?", tagIds). + Group("resource_id") + + if err := db.Model(&model.Resource{}). + Where("id IN (?)", subQuery). + Count(&total).Error; err != nil { + return nil, 0, err + } + + if err := db.Where("id IN (?)", subQuery). + Offset((page - 1) * pageSize). + Limit(pageSize). + Preload("User"). + Preload("Images"). + Preload("Tags"). + Preload("Files"). + Order("created_at DESC"). + Find(&resources).Error; err != nil { + return nil, 0, err + } + + totalPages := (total + int64(pageSize) - 1) / int64(pageSize) + + return resources, int(totalPages), nil } func ExistsResource(id uint) (bool, error) { diff --git a/server/dao/tag.go b/server/dao/tag.go index f80d47d..9b23f67 100644 --- a/server/dao/tag.go +++ b/server/dao/tag.go @@ -16,10 +16,14 @@ func CreateTag(tag string) (model.Tag, error) { return t, nil } -func SearchTag(keyword string) ([]model.Tag, error) { +func SearchTag(keyword string, mainTag bool) ([]model.Tag, error) { // Search for a tag by its name in the database var t []model.Tag - if err := db.Model(&model.Tag{}).Where("name Like ?", "%"+keyword+"%").Limit(10).Find(&t).Error; err != nil { + query := db.Model(&model.Tag{}).Where("name Like ?", "%"+keyword+"%") + if mainTag { + query = query.Where("alias_of IS NULL") + } + if err := query.Limit(10).Find(&t).Error; err != nil { return nil, err } return t, nil @@ -38,7 +42,7 @@ func DeleteTag(id uint) error { func GetTagByID(id uint) (model.Tag, error) { // Retrieve a tag by its ID from the database var t model.Tag - if err := db.First(&t, id).Error; err != nil { + if err := db.Preload("Aliases").First(&t, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return model.Tag{}, model.NewNotFoundError("Tag not found") } @@ -50,7 +54,7 @@ func GetTagByID(id uint) (model.Tag, error) { func GetTagByName(name string) (model.Tag, error) { // Retrieve a tag by its name from the database var t model.Tag - if err := db.Where("name = ?", name).First(&t).Error; err != nil { + if err := db.Preload("Aliases").Where("name = ?", name).First(&t).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return model.Tag{}, model.NewNotFoundError("Tag not found") } @@ -59,8 +63,14 @@ func GetTagByName(name string) (model.Tag, error) { return t, nil } -func SetTagDescription(id uint, description string) error { - if err := db.Model(model.Tag{}).Where("id = ?", id).Update("description", description).Error; err != nil { +func SetTagInfo(id uint, description string, aliasOf *uint, tagType string) error { + t := model.Tag{Model: gorm.Model{ + ID: id, + }, Description: description, Type: tagType, AliasOf: aliasOf} + if err := db.Model(&t).Updates(t).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.NewNotFoundError("Tag not found") + } return err } return nil diff --git a/server/model/tag.go b/server/model/tag.go index 524c808..75dd380 100644 --- a/server/model/tag.go +++ b/server/model/tag.go @@ -6,19 +6,30 @@ type Tag struct { gorm.Model Name string `gorm:"unique"` Description string + AliasOf *uint `gorm:"default:NULL"` // Foreign key for aliasing, can be NULL + Type string Resources []Resource `gorm:"many2many:resource_tags;"` + Aliases []Tag `gorm:"foreignKey:AliasOf;references:ID"` } type TagView struct { - ID uint `json:"id"` - Name string `json:"name"` - Description string `json:"description"` + ID uint `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Aliases []string `json:"aliases"` } func (t *Tag) ToView() *TagView { + aliases := make([]string, 0, len(t.Aliases)) + for _, alias := range t.Aliases { + aliases = append(aliases, alias.Name) + } return &TagView{ ID: t.ID, Name: t.Name, Description: t.Description, + Type: t.Type, + Aliases: aliases, } } diff --git a/server/service/tag.go b/server/service/tag.go index f671132..f4b9ac8 100644 --- a/server/service/tag.go +++ b/server/service/tag.go @@ -27,6 +27,12 @@ func GetTag(id uint) (*model.TagView, error) { if err != nil { return nil, err } + if t.AliasOf != nil { + t, err = dao.GetTagByID(*t.AliasOf) + if err != nil { + return nil, err + } + } return t.ToView(), nil } @@ -35,11 +41,17 @@ func GetTagByName(name string) (*model.TagView, error) { if err != nil { return nil, err } + if t.AliasOf != nil { + t, err = dao.GetTagByID(*t.AliasOf) + if err != nil { + return nil, err + } + } return t.ToView(), nil } -func SearchTag(name string) ([]model.TagView, error) { - tags, err := dao.SearchTag(name) +func SearchTag(name string, mainTag bool) ([]model.TagView, error) { + tags, err := dao.SearchTag(name, mainTag) if err != nil { return nil, err } @@ -54,7 +66,7 @@ func DeleteTag(id uint) error { return dao.DeleteTag(id) } -func SetTagDescription(uid uint, id uint, description string) (*model.TagView, error) { +func SetTagInfo(uid uint, id uint, description string, aliasOf *uint, tagType string) (*model.TagView, error) { canUpload, err := checkUserCanUpload(uid) if err != nil { log.Error("Error checking user permissions:", err) @@ -63,13 +75,18 @@ func SetTagDescription(uid uint, id uint, description string) (*model.TagView, e if !canUpload { return nil, model.NewUnAuthorizedError("User cannot set tag description") } + if err := dao.SetTagInfo(id, description, aliasOf, tagType); err != nil { + return nil, err + } t, err := dao.GetTagByID(id) if err != nil { return nil, err } - t.Description = description - if err := dao.SetTagDescription(id, description); err != nil { - return nil, err + if t.AliasOf != nil { + t, err = dao.GetTagByID(*t.AliasOf) + if err != nil { + return nil, err + } } return t.ToView(), nil }