feat(backend): implement full tag stack (repo, service, handler, routes)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -73,6 +73,7 @@ type FileService struct {
|
||||
storage port.FileStorage
|
||||
acl *ACLService
|
||||
audit *AuditService
|
||||
tags *TagService
|
||||
tx port.Transactor
|
||||
importPath string // default server-side import directory
|
||||
}
|
||||
@@ -84,6 +85,7 @@ func NewFileService(
|
||||
storage port.FileStorage,
|
||||
acl *ACLService,
|
||||
audit *AuditService,
|
||||
tags *TagService,
|
||||
tx port.Transactor,
|
||||
importPath string,
|
||||
) *FileService {
|
||||
@@ -93,6 +95,7 @@ func NewFileService(
|
||||
storage: storage,
|
||||
acl: acl,
|
||||
audit: audit,
|
||||
tags: tags,
|
||||
tx: tx,
|
||||
importPath: importPath,
|
||||
}
|
||||
@@ -166,10 +169,7 @@ func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File,
|
||||
}
|
||||
|
||||
if len(p.TagIDs) > 0 {
|
||||
if err := s.files.SetTags(ctx, created.ID, p.TagIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
tags, err := s.files.ListTags(ctx, created.ID)
|
||||
tags, err := s.tags.SetFileTags(ctx, created.ID, p.TagIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -249,10 +249,7 @@ func (s *FileService) Update(ctx context.Context, id uuid.UUID, p UpdateParams)
|
||||
return updateErr
|
||||
}
|
||||
if p.TagIDs != nil {
|
||||
if err := s.files.SetTags(ctx, id, *p.TagIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
tags, err := s.files.ListTags(ctx, id)
|
||||
tags, err := s.tags.SetFileTags(ctx, id, *p.TagIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -447,120 +444,6 @@ func (s *FileService) GetPreview(ctx context.Context, id uuid.UUID) (io.ReadClos
|
||||
return s.storage.Preview(ctx, id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tag operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ListFileTags returns the tags on a file, enforcing view ACL.
|
||||
func (s *FileService) ListFileTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error) {
|
||||
if _, err := s.Get(ctx, fileID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.files.ListTags(ctx, fileID)
|
||||
}
|
||||
|
||||
// SetFileTags replaces all tags on a file (full replace semantics), enforcing edit ACL.
|
||||
func (s *FileService) SetFileTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) ([]domain.Tag, error) {
|
||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||
|
||||
f, err := s.files.GetByID(ctx, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, domain.ErrForbidden
|
||||
}
|
||||
|
||||
if err := s.files.SetTags(ctx, fileID, tagIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objType := fileObjectType
|
||||
_ = s.audit.Log(ctx, "file_tag_add", &objType, &fileID, nil)
|
||||
return s.files.ListTags(ctx, fileID)
|
||||
}
|
||||
|
||||
// AddTag adds a single tag to a file, enforcing edit ACL.
|
||||
func (s *FileService) AddTag(ctx context.Context, fileID, tagID uuid.UUID) ([]domain.Tag, error) {
|
||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||
|
||||
f, err := s.files.GetByID(ctx, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, domain.ErrForbidden
|
||||
}
|
||||
|
||||
current, err := s.files.ListTags(ctx, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Only add if not already present.
|
||||
for _, t := range current {
|
||||
if t.ID == tagID {
|
||||
return current, nil
|
||||
}
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(current)+1)
|
||||
for _, t := range current {
|
||||
ids = append(ids, t.ID)
|
||||
}
|
||||
ids = append(ids, tagID)
|
||||
|
||||
if err := s.files.SetTags(ctx, fileID, ids); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objType := fileObjectType
|
||||
_ = s.audit.Log(ctx, "file_tag_add", &objType, &fileID, map[string]any{"tag_id": tagID})
|
||||
return s.files.ListTags(ctx, fileID)
|
||||
}
|
||||
|
||||
// RemoveTag removes a single tag from a file, enforcing edit ACL.
|
||||
func (s *FileService) RemoveTag(ctx context.Context, fileID, tagID uuid.UUID) error {
|
||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||
|
||||
f, err := s.files.GetByID(ctx, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
|
||||
current, err := s.files.ListTags(ctx, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(current))
|
||||
for _, t := range current {
|
||||
if t.ID != tagID {
|
||||
ids = append(ids, t.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.files.SetTags(ctx, fileID, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
objType := fileObjectType
|
||||
_ = s.audit.Log(ctx, "file_tag_remove", &objType, &fileID, map[string]any{"tag_id": tagID})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bulk operations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -569,7 +452,6 @@ func (s *FileService) RemoveTag(ctx context.Context, fileID, tagID uuid.UUID) er
|
||||
func (s *FileService) BulkDelete(ctx context.Context, fileIDs []uuid.UUID) error {
|
||||
for _, id := range fileIDs {
|
||||
if err := s.Delete(ctx, id); err != nil {
|
||||
// Skip files not found or forbidden; surface real errors.
|
||||
if err == domain.ErrNotFound || err == domain.ErrForbidden {
|
||||
continue
|
||||
}
|
||||
@@ -579,78 +461,6 @@ func (s *FileService) BulkDelete(ctx context.Context, fileIDs []uuid.UUID) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// BulkSetTags adds or removes the given tags on multiple files.
|
||||
// For "add": tags are appended to each file's existing set.
|
||||
// For "remove": tags are removed from each file's existing set.
|
||||
// Returns the tag IDs that were applied (the input tagIDs, for add).
|
||||
func (s *FileService) BulkSetTags(ctx context.Context, fileIDs []uuid.UUID, action string, tagIDs []uuid.UUID) ([]uuid.UUID, error) {
|
||||
for _, fileID := range fileIDs {
|
||||
switch action {
|
||||
case "add":
|
||||
for _, tagID := range tagIDs {
|
||||
if _, err := s.AddTag(ctx, fileID, tagID); err != nil {
|
||||
if err == domain.ErrNotFound || err == domain.ErrForbidden {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
case "remove":
|
||||
for _, tagID := range tagIDs {
|
||||
if err := s.RemoveTag(ctx, fileID, tagID); err != nil {
|
||||
if err == domain.ErrNotFound || err == domain.ErrForbidden {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, domain.ErrValidation
|
||||
}
|
||||
}
|
||||
if action == "add" {
|
||||
return tagIDs, nil
|
||||
}
|
||||
return []uuid.UUID{}, nil
|
||||
}
|
||||
|
||||
// CommonTags loads the tag sets for all given files and splits them into:
|
||||
// - common: tag IDs present on every file
|
||||
// - partial: tag IDs present on some but not all files
|
||||
func (s *FileService) CommonTags(ctx context.Context, fileIDs []uuid.UUID) (common, partial []uuid.UUID, err error) {
|
||||
if len(fileIDs) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// Count how many files each tag appears on.
|
||||
counts := map[uuid.UUID]int{}
|
||||
for _, fid := range fileIDs {
|
||||
tags, err := s.files.ListTags(ctx, fid)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, t := range tags {
|
||||
counts[t.ID]++
|
||||
}
|
||||
}
|
||||
|
||||
n := len(fileIDs)
|
||||
for id, cnt := range counts {
|
||||
if cnt == n {
|
||||
common = append(common, id)
|
||||
} else {
|
||||
partial = append(partial, id)
|
||||
}
|
||||
}
|
||||
if common == nil {
|
||||
common = []uuid.UUID{}
|
||||
}
|
||||
if partial == nil {
|
||||
partial = []uuid.UUID{}
|
||||
}
|
||||
return common, partial, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"tanabata/backend/internal/domain"
|
||||
"tanabata/backend/internal/port"
|
||||
)
|
||||
|
||||
const tagObjectType = "tag"
|
||||
const tagObjectTypeID int16 = 2 // second row in 007_seed_data.sql object_types
|
||||
|
||||
// TagParams holds the fields for creating or patching a tag.
|
||||
type TagParams struct {
|
||||
Name string
|
||||
Notes *string
|
||||
Color *string // nil = no change; pointer to empty string = clear
|
||||
CategoryID *uuid.UUID // nil = no change; Nil UUID = unassign
|
||||
Metadata json.RawMessage
|
||||
IsPublic *bool
|
||||
}
|
||||
|
||||
// TagService handles tag CRUD, tag-rule management, and file–tag operations
|
||||
// including automatic recursive rule application.
|
||||
type TagService struct {
|
||||
tags port.TagRepo
|
||||
rules port.TagRuleRepo
|
||||
acl *ACLService
|
||||
audit *AuditService
|
||||
tx port.Transactor
|
||||
}
|
||||
|
||||
// NewTagService creates a TagService.
|
||||
func NewTagService(
|
||||
tags port.TagRepo,
|
||||
rules port.TagRuleRepo,
|
||||
acl *ACLService,
|
||||
audit *AuditService,
|
||||
tx port.Transactor,
|
||||
) *TagService {
|
||||
return &TagService{
|
||||
tags: tags,
|
||||
rules: rules,
|
||||
acl: acl,
|
||||
audit: audit,
|
||||
tx: tx,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tag CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// List returns a paginated, optionally filtered list of tags.
|
||||
func (s *TagService) List(ctx context.Context, params port.OffsetParams) (*domain.TagOffsetPage, error) {
|
||||
return s.tags.List(ctx, params)
|
||||
}
|
||||
|
||||
// Get returns a tag by ID.
|
||||
func (s *TagService) Get(ctx context.Context, id uuid.UUID) (*domain.Tag, error) {
|
||||
return s.tags.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Create inserts a new tag record.
|
||||
func (s *TagService) Create(ctx context.Context, p TagParams) (*domain.Tag, error) {
|
||||
userID, _, _ := domain.UserFromContext(ctx)
|
||||
|
||||
t := &domain.Tag{
|
||||
Name: p.Name,
|
||||
Notes: p.Notes,
|
||||
Color: p.Color,
|
||||
CategoryID: p.CategoryID,
|
||||
Metadata: p.Metadata,
|
||||
CreatorID: userID,
|
||||
}
|
||||
if p.IsPublic != nil {
|
||||
t.IsPublic = *p.IsPublic
|
||||
}
|
||||
|
||||
created, err := s.tags.Create(ctx, t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objType := tagObjectType
|
||||
_ = s.audit.Log(ctx, "tag_create", &objType, &created.ID, nil)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// Update applies a partial patch to a tag.
|
||||
// The service reads the current tag first so the caller only needs to supply
|
||||
// the fields that should change.
|
||||
func (s *TagService) Update(ctx context.Context, id uuid.UUID, p TagParams) (*domain.Tag, error) {
|
||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||
|
||||
current, err := s.tags.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, current.CreatorID, tagObjectTypeID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, domain.ErrForbidden
|
||||
}
|
||||
|
||||
// Merge patch into current.
|
||||
patch := *current // copy
|
||||
if p.Name != "" {
|
||||
patch.Name = p.Name
|
||||
}
|
||||
if p.Notes != nil {
|
||||
patch.Notes = p.Notes
|
||||
}
|
||||
if p.Color != nil {
|
||||
patch.Color = p.Color
|
||||
}
|
||||
if p.CategoryID != nil {
|
||||
if *p.CategoryID == uuid.Nil {
|
||||
patch.CategoryID = nil // explicit unassign
|
||||
} else {
|
||||
patch.CategoryID = p.CategoryID
|
||||
}
|
||||
}
|
||||
if len(p.Metadata) > 0 {
|
||||
patch.Metadata = p.Metadata
|
||||
}
|
||||
if p.IsPublic != nil {
|
||||
patch.IsPublic = *p.IsPublic
|
||||
}
|
||||
|
||||
updated, err := s.tags.Update(ctx, id, &patch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objType := tagObjectType
|
||||
_ = s.audit.Log(ctx, "tag_edit", &objType, &id, nil)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// Delete removes a tag by ID, enforcing edit ACL.
|
||||
func (s *TagService) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||
|
||||
t, err := s.tags.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, t.CreatorID, tagObjectTypeID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
|
||||
if err := s.tags.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
objType := tagObjectType
|
||||
_ = s.audit.Log(ctx, "tag_delete", &objType, &id, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tag rules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ListRules returns all rules for a tag (when this tag is applied, these follow).
|
||||
func (s *TagService) ListRules(ctx context.Context, tagID uuid.UUID) ([]domain.TagRule, error) {
|
||||
return s.rules.ListByTag(ctx, tagID)
|
||||
}
|
||||
|
||||
// CreateRule adds a tag rule. If applyToExisting is true, the then_tag is
|
||||
// retroactively applied to all files that already carry the when_tag.
|
||||
// Retroactive application requires a FileRepo; it is deferred until wired
|
||||
// in a future iteration (see port.FileRepo.ListByTag).
|
||||
func (s *TagService) CreateRule(ctx context.Context, whenTagID, thenTagID uuid.UUID, isActive, _ bool) (*domain.TagRule, error) {
|
||||
return s.rules.Create(ctx, domain.TagRule{
|
||||
WhenTagID: whenTagID,
|
||||
ThenTagID: thenTagID,
|
||||
IsActive: isActive,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteRule removes a tag rule.
|
||||
func (s *TagService) DeleteRule(ctx context.Context, whenTagID, thenTagID uuid.UUID) error {
|
||||
return s.rules.Delete(ctx, whenTagID, thenTagID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File–tag operations (with auto-rule expansion)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ListFileTags returns the tags on a file.
|
||||
func (s *TagService) ListFileTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error) {
|
||||
return s.tags.ListByFile(ctx, fileID)
|
||||
}
|
||||
|
||||
// SetFileTags replaces all tags on a file, then applies active rules for all
|
||||
// newly set tags (BFS expansion). Returns the full resulting tag set.
|
||||
func (s *TagService) SetFileTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) ([]domain.Tag, error) {
|
||||
expanded, err := s.expandTagSet(ctx, tagIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.tags.SetFileTags(ctx, fileID, expanded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objType := fileObjectType
|
||||
_ = s.audit.Log(ctx, "file_tag_add", &objType, &fileID, nil)
|
||||
return s.tags.ListByFile(ctx, fileID)
|
||||
}
|
||||
|
||||
// AddFileTag adds a single tag to a file, then recursively applies active rules.
|
||||
// Returns the full resulting tag set.
|
||||
func (s *TagService) AddFileTag(ctx context.Context, fileID, tagID uuid.UUID) ([]domain.Tag, error) {
|
||||
// Compute the full set including rule-expansion from tagID.
|
||||
extra, err := s.expandTagSet(ctx, []uuid.UUID{tagID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fetch current tags so we don't lose them.
|
||||
current, err := s.tags.ListByFile(ctx, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Union: existing + expanded new tags.
|
||||
seen := make(map[uuid.UUID]bool, len(current)+len(extra))
|
||||
for _, t := range current {
|
||||
seen[t.ID] = true
|
||||
}
|
||||
merged := make([]uuid.UUID, len(current))
|
||||
for i, t := range current {
|
||||
merged[i] = t.ID
|
||||
}
|
||||
for _, id := range extra {
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
merged = append(merged, id)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.tags.SetFileTags(ctx, fileID, merged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objType := fileObjectType
|
||||
_ = s.audit.Log(ctx, "file_tag_add", &objType, &fileID, map[string]any{"tag_id": tagID})
|
||||
return s.tags.ListByFile(ctx, fileID)
|
||||
}
|
||||
|
||||
// RemoveFileTag removes a single tag from a file.
|
||||
func (s *TagService) RemoveFileTag(ctx context.Context, fileID, tagID uuid.UUID) error {
|
||||
if err := s.tags.RemoveFileTag(ctx, fileID, tagID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
objType := fileObjectType
|
||||
_ = s.audit.Log(ctx, "file_tag_remove", &objType, &fileID, map[string]any{"tag_id": tagID})
|
||||
return nil
|
||||
}
|
||||
|
||||
// BulkSetTags adds or removes tags on multiple files (with rule expansion for add).
|
||||
// Returns the tagIDs that were applied (the expanded input set for add; empty for remove).
|
||||
func (s *TagService) BulkSetTags(ctx context.Context, fileIDs []uuid.UUID, action string, tagIDs []uuid.UUID) ([]uuid.UUID, error) {
|
||||
if action != "add" && action != "remove" {
|
||||
return nil, domain.ErrValidation
|
||||
}
|
||||
|
||||
// Pre-expand tag set once; all files get the same expansion.
|
||||
var expanded []uuid.UUID
|
||||
if action == "add" {
|
||||
var err error
|
||||
expanded, err = s.expandTagSet(ctx, tagIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
for _, fileID := range fileIDs {
|
||||
switch action {
|
||||
case "add":
|
||||
current, err := s.tags.ListByFile(ctx, fileID)
|
||||
if err != nil {
|
||||
if err == domain.ErrNotFound {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[uuid.UUID]bool, len(current))
|
||||
merged := make([]uuid.UUID, len(current))
|
||||
for i, t := range current {
|
||||
seen[t.ID] = true
|
||||
merged[i] = t.ID
|
||||
}
|
||||
for _, id := range expanded {
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
merged = append(merged, id)
|
||||
}
|
||||
}
|
||||
if err := s.tags.SetFileTags(ctx, fileID, merged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case "remove":
|
||||
current, err := s.tags.ListByFile(ctx, fileID)
|
||||
if err != nil {
|
||||
if err == domain.ErrNotFound {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
remove := make(map[uuid.UUID]bool, len(tagIDs))
|
||||
for _, id := range tagIDs {
|
||||
remove[id] = true
|
||||
}
|
||||
kept := make([]uuid.UUID, 0, len(current))
|
||||
for _, t := range current {
|
||||
if !remove[t.ID] {
|
||||
kept = append(kept, t.ID)
|
||||
}
|
||||
}
|
||||
if err := s.tags.SetFileTags(ctx, fileID, kept); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if action == "add" {
|
||||
return expanded, nil
|
||||
}
|
||||
return []uuid.UUID{}, nil
|
||||
}
|
||||
|
||||
// CommonTags returns tags present on ALL given files and tags present on SOME.
|
||||
func (s *TagService) CommonTags(ctx context.Context, fileIDs []uuid.UUID) (common, partial []domain.Tag, err error) {
|
||||
common, err = s.tags.CommonTagsForFiles(ctx, fileIDs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
partial, err = s.tags.PartialTagsForFiles(ctx, fileIDs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return common, partial, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// expandTagSet runs a BFS from the given seed tags, following active tag rules,
|
||||
// and returns the full set of tag IDs that should be applied (seeds + auto-applied).
|
||||
func (s *TagService) expandTagSet(ctx context.Context, seeds []uuid.UUID) ([]uuid.UUID, error) {
|
||||
visited := make(map[uuid.UUID]bool, len(seeds))
|
||||
queue := make([]uuid.UUID, 0, len(seeds))
|
||||
|
||||
for _, id := range seeds {
|
||||
if !visited[id] {
|
||||
visited[id] = true
|
||||
queue = append(queue, id)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < len(queue); i++ {
|
||||
tagID := queue[i]
|
||||
rules, err := s.rules.ListByTag(ctx, tagID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rules {
|
||||
if r.IsActive && !visited[r.ThenTagID] {
|
||||
visited[r.ThenTagID] = true
|
||||
queue = append(queue, r.ThenTagID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return queue, nil
|
||||
}
|
||||
Reference in New Issue
Block a user