diff --git a/internal/api/edit_comment_links_integration_test.go b/internal/api/edit_comment_links_integration_test.go new file mode 100644 index 000000000..0f427159d --- /dev/null +++ b/internal/api/edit_comment_links_integration_test.go @@ -0,0 +1,81 @@ +//go:build integration + +package api_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/stashapp/stash-box/internal/models" + "github.com/stretchr/testify/assert" +) + +// findCommentContaining returns the first comment whose text contains substr. +func findCommentContaining(comments []models.EditComment, substr string) *models.EditComment { + for i := range comments { + if strings.Contains(comments[i].Text, substr) { + return &comments[i] + } + } + return nil +} + +func TestEditCommentLinksEntities(t *testing.T) { + editor := asModify(t) + + // A real tag to reference, plus a non-existent UUID that should stay bare. + tag, err := editor.createTestTag(nil) + assert.NoError(t, err) + tagID := tag.UUID() + missingID := "00000000-0000-0000-0000-000000000000" + + edit, err := editor.createTestTagEdit(models.OperationEnumCreate, nil, nil) + assert.NoError(t, err) + + // Reference the tag bare, an unknown UUID, one inside a URL, and one already + // inside a markdown link. + comment := fmt.Sprintf("dup of %s, unknown %s, see /tags/%s, link [%s](x)", tagID, missingID, tagID, tagID) + _, err = editor.resolver.Mutation().EditComment(editor.ctx, models.EditCommentInput{ + ID: edit.ID, + Comment: comment, + }) + assert.NoError(t, err) + + comments, err := editor.resolver.Edit().Comments(editor.ctx, edit) + assert.NoError(t, err) + stored := findCommentContaining(comments, "dup of") + assert.NotNil(t, stored) + + // The bare UUID becomes a markdown link to the tag. + assert.Contains(t, stored.Text, fmt.Sprintf("[%s](/tags/%s)", tagID, tagID)) + // An unresolvable UUID is left untouched. + assert.Contains(t, stored.Text, fmt.Sprintf("unknown %s", missingID)) + assert.NotContains(t, stored.Text, fmt.Sprintf("[%s]", missingID)) + // A UUID already part of a URL is not linked again. + assert.Contains(t, stored.Text, fmt.Sprintf("see /tags/%s", tagID)) + assert.NotContains(t, stored.Text, fmt.Sprintf("/tags/[%s]", tagID)) + // A UUID already inside a markdown link is left intact. + assert.Contains(t, stored.Text, fmt.Sprintf("link [%s](x)", tagID)) +} + +func TestEditSubmissionCommentLinksEntities(t *testing.T) { + editor := asModify(t) + + tag, err := editor.createTestTag(nil) + assert.NoError(t, err) + tagID := tag.UUID() + + submission := fmt.Sprintf("split from %s", tagID) + edit, err := editor.createTestTagEdit(models.OperationEnumCreate, nil, &models.EditInput{ + Operation: models.OperationEnumCreate, + Comment: &submission, + }) + assert.NoError(t, err) + + comments, err := editor.resolver.Edit().Comments(editor.ctx, edit) + assert.NoError(t, err) + stored := findCommentContaining(comments, "split from") + assert.NotNil(t, stored) + assert.Contains(t, stored.Text, fmt.Sprintf("[%s](/tags/%s)", tagID, tagID)) +} diff --git a/internal/queries/edit.sql.go b/internal/queries/edit.sql.go index be3a673c0..838e4b596 100644 --- a/internal/queries/edit.sql.go +++ b/internal/queries/edit.sql.go @@ -1246,6 +1246,43 @@ func (q *Queries) ResetVotes(ctx context.Context, editID uuid.UUID) error { return err } +const resolveEntityTypes = `-- name: ResolveEntityTypes :many +SELECT id, 'PERFORMER'::TEXT AS entity_type FROM performers WHERE id = ANY($1::UUID[]) +UNION ALL +SELECT id, 'SCENE'::TEXT FROM scenes WHERE id = ANY($1::UUID[]) +UNION ALL +SELECT id, 'STUDIO'::TEXT FROM studios WHERE id = ANY($1::UUID[]) +UNION ALL +SELECT id, 'TAG'::TEXT FROM tags WHERE id = ANY($1::UUID[]) +` + +type ResolveEntityTypesRow struct { + ID uuid.UUID `db:"id" json:"id"` + EntityType string `db:"entity_type" json:"entity_type"` +} + +// Resolves a set of UUIDs to the type of entity they belong to, used to turn +// bare UUIDs in comments into links. +func (q *Queries) ResolveEntityTypes(ctx context.Context, ids []uuid.UUID) ([]ResolveEntityTypesRow, error) { + rows, err := q.db.Query(ctx, resolveEntityTypes, ids) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ResolveEntityTypesRow{} + for rows.Next() { + var i ResolveEntityTypesRow + if err := rows.Scan(&i.ID, &i.EntityType); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const setEditCommentHidden = `-- name: SetEditCommentHidden :one UPDATE edit_comments SET is_hidden = $2 WHERE id = $1 RETURNING id, edit_id, user_id, created_at, text, updated_at, is_hidden ` diff --git a/internal/queries/querier.go b/internal/queries/querier.go index 746736483..3a18e9aca 100644 --- a/internal/queries/querier.go +++ b/internal/queries/querier.go @@ -316,6 +316,9 @@ type Querier interface { ReassignPerformerFavorites(ctx context.Context, arg ReassignPerformerFavoritesParams) error ReassignStudioFavorites(ctx context.Context, arg ReassignStudioFavoritesParams) error ResetVotes(ctx context.Context, editID uuid.UUID) error + // Resolves a set of UUIDs to the type of entity they belong to, used to turn + // bare UUIDs in comments into links. + ResolveEntityTypes(ctx context.Context, ids []uuid.UUID) ([]ResolveEntityTypesRow, error) // Keep the WHERE clause in sync across SearchPerformers, CountPerformerSearchMatches, // and GetPerformerSearchFacets so paging, counts, and facets stay consistent. SearchPerformers(ctx context.Context, arg SearchPerformersParams) ([]uuid.UUID, error) diff --git a/internal/queries/sql/edit.sql b/internal/queries/sql/edit.sql index ec4c6ef02..43bf3a343 100644 --- a/internal/queries/sql/edit.sql +++ b/internal/queries/sql/edit.sql @@ -415,3 +415,14 @@ SELECT * FROM edit_comments WHERE id = ANY($1::UUID[]); -- name: UpdateEditData :one UPDATE edits SET data = $2, updated_at = now() WHERE id = $1 RETURNING *; + +-- name: ResolveEntityTypes :many +-- Resolves a set of UUIDs to the type of entity they belong to, used to turn +-- bare UUIDs in comments into links. +SELECT id, 'PERFORMER'::TEXT AS entity_type FROM performers WHERE id = ANY(sqlc.arg(ids)::UUID[]) +UNION ALL +SELECT id, 'SCENE'::TEXT FROM scenes WHERE id = ANY(sqlc.arg(ids)::UUID[]) +UNION ALL +SELECT id, 'STUDIO'::TEXT FROM studios WHERE id = ANY(sqlc.arg(ids)::UUID[]) +UNION ALL +SELECT id, 'TAG'::TEXT FROM tags WHERE id = ANY(sqlc.arg(ids)::UUID[]); diff --git a/internal/service/edit/comment_links.go b/internal/service/edit/comment_links.go new file mode 100644 index 000000000..965a41166 --- /dev/null +++ b/internal/service/edit/comment_links.go @@ -0,0 +1,88 @@ +package edit + +import ( + "context" + "regexp" + "strings" + + "github.com/gofrs/uuid" + + "github.com/stashapp/stash-box/internal/models" + "github.com/stashapp/stash-box/internal/queries" +) + +// Matches a whitespace-delimited UUID. Requiring leading whitespace (or the +// start of the string) leaves UUIDs that are already part of a link or URL +// (e.g. [uuid](id) or /tags/uuid) untouched. The trailing \b is non-consuming +// so adjacent UUIDs separated by a single space are both matched. +var commentUUIDRe = regexp.MustCompile(`(?i)(?:^|\s)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b`) + +// Frontend path each entity type is reachable at. +var entityTypePaths = map[string]string{ + models.TargetTypeEnumPerformer.String(): "/performers/", + models.TargetTypeEnumScene.String(): "/scenes/", + models.TargetTypeEnumStudio.String(): "/studios/", + models.TargetTypeEnumTag.String(): "/tags/", +} + +// parseCommentUUIDs returns the distinct whitespace-delimited UUIDs in text. +func parseCommentUUIDs(text string) []uuid.UUID { + matches := commentUUIDRe.FindAllString(text, -1) + ids := make([]uuid.UUID, 0, len(matches)) + seen := make(map[uuid.UUID]struct{}) + for _, m := range matches { + id, err := uuid.FromString(strings.TrimSpace(m)) + if err != nil { + continue + } + if _, ok := seen[id]; !ok { + seen[id] = struct{}{} + ids = append(ids, id) + } + } + return ids +} + +// replaceCommentUUIDs rewrites each UUID found in paths into a markdown link to +// the given path, leaving everything else untouched. +func replaceCommentUUIDs(text string, paths map[uuid.UUID]string) string { + if len(paths) == 0 { + return text + } + return commentUUIDRe.ReplaceAllStringFunc(text, func(match string) string { + raw := strings.TrimSpace(match) + id, err := uuid.FromString(raw) + if err != nil { + return match + } + path, ok := paths[id] + if !ok { + return match + } + // Preserve the leading whitespace consumed by the pattern. + return match[:len(match)-len(raw)] + "[" + raw + "](" + path + raw + ")" + }) +} + +// linkCommentEntities rewrites bare UUIDs in a comment into markdown links that +// point to the entity they identify. +func linkCommentEntities(ctx context.Context, q *queries.Queries, text string) (string, error) { + ids := parseCommentUUIDs(text) + if len(ids) == 0 { + return text, nil + } + + rows, err := q.ResolveEntityTypes(ctx, ids) + if err != nil { + return text, err + } + + paths := make(map[uuid.UUID]string, len(rows)) + for _, row := range rows { + if path, ok := entityTypePaths[row.EntityType]; ok { + paths[row.ID] = path + } + } + + return replaceCommentUUIDs(text, paths), nil +} diff --git a/internal/service/edit/comment_links_test.go b/internal/service/edit/comment_links_test.go new file mode 100644 index 000000000..a17ee0423 --- /dev/null +++ b/internal/service/edit/comment_links_test.go @@ -0,0 +1,119 @@ +package edit + +import ( + "testing" + + "github.com/gofrs/uuid" +) + +func mustUUID(t *testing.T, s string) uuid.UUID { + t.Helper() + id, err := uuid.FromString(s) + if err != nil { + t.Fatalf("invalid uuid %q: %v", s, err) + } + return id +} + +func TestParseCommentUUIDs(t *testing.T) { + a := "11111111-1111-1111-1111-111111111111" + b := "22222222-2222-2222-2222-222222222222" + + tests := []struct { + name string + text string + want []string + }{ + {"none", "no uuids here", nil}, + {"bare", "dup of " + a, []string{a}}, + {"at start", a + " is a dup", []string{a}}, + {"trailing punctuation", "see " + a + ".", []string{a}}, + {"adjacent single space", a + " " + b, []string{a, b}}, + {"deduplicated", a + " and " + a, []string{a}}, + {"uppercase normalized", "ID 11111111-1111-1111-1111-11111111AAAA", []string{"11111111-1111-1111-1111-11111111aaaa"}}, + {"inside url skipped", "see /tags/" + a, nil}, + {"inside markdown link skipped", "[" + a + "](x)", nil}, + {"no separator skipped", "x" + a, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseCommentUUIDs(tt.text) + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i, id := range got { + if id != mustUUID(t, tt.want[i]) { + t.Errorf("index %d: got %s, want %s", i, id, tt.want[i]) + } + } + }) + } +} + +func TestReplaceCommentUUIDs(t *testing.T) { + a := "11111111-1111-1111-1111-111111111111" + b := "22222222-2222-2222-2222-222222222222" + + paths := map[uuid.UUID]string{ + mustUUID(t, a): "/scenes/", + mustUUID(t, b): "/performers/", + } + + tests := []struct { + name string + text string + want string + }{ + { + name: "bare uuid linked", + text: "dup of " + a, + want: "dup of [" + a + "](/scenes/" + a + ")", + }, + { + name: "uuid at start linked without leading space", + text: a + " end", + want: "[" + a + "](/scenes/" + a + ") end", + }, + { + name: "different types use different paths", + text: a + " " + b, + want: "[" + a + "](/scenes/" + a + ") [" + b + "](/performers/" + b + ")", + }, + { + name: "unresolved uuid left bare", + text: "unknown 33333333-3333-3333-3333-333333333333", + want: "unknown 33333333-3333-3333-3333-333333333333", + }, + { + name: "url not double linked", + text: "see /scenes/" + a, + want: "see /scenes/" + a, + }, + { + name: "existing markdown link untouched", + text: "link [" + a + "](x)", + want: "link [" + a + "](x)", + }, + { + name: "trailing punctuation preserved", + text: "see " + a + ", ok", + want: "see [" + a + "](/scenes/" + a + "), ok", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := replaceCommentUUIDs(tt.text, paths); got != tt.want { + t.Errorf("got %q\nwant %q", got, tt.want) + } + }) + } +} + +func TestReplaceCommentUUIDsNoPaths(t *testing.T) { + text := "dup of 11111111-1111-1111-1111-111111111111" + if got := replaceCommentUUIDs(text, nil); got != text { + t.Errorf("got %q, want unchanged %q", got, text) + } +} diff --git a/internal/service/edit/edit.go b/internal/service/edit/edit.go index dde34bae2..d38d8158a 100644 --- a/internal/service/edit/edit.go +++ b/internal/service/edit/edit.go @@ -60,9 +60,13 @@ func (m *mutator) UpdateEdit() (*models.Edit, error) { func (m *mutator) CreateComment(userID uuid.UUID, comment *string) error { if comment != nil && len(*comment) > 0 { + text, err := linkCommentEntities(m.context, m.queries, *comment) + if err != nil { + return err + } commentID, _ := uuid.NewV7() - comment := models.NewEditComment(commentID, userID, m.edit, *comment) - _, err := m.queries.CreateEditComment(m.context, converter.EditCommentToCreateParams(*comment)) + comment := models.NewEditComment(commentID, userID, m.edit, text) + _, err = m.queries.CreateEditComment(m.context, converter.EditCommentToCreateParams(*comment)) return err } diff --git a/internal/service/edit/service.go b/internal/service/edit/service.go index a1635d0b5..dcad0ea63 100644 --- a/internal/service/edit/service.go +++ b/internal/service/edit/service.go @@ -1041,7 +1041,11 @@ func (s *Edit) CreateComment(ctx context.Context, input models.EditCommentInput) var comment *models.EditComment err = s.withTxn(func(tx *queries.Queries) error { currentUser := auth.GetCurrentUser(ctx) - params, err := converter.CreateEditCommentParams(edit.ID, currentUser.ID, input.Comment) + text, err := linkCommentEntities(ctx, tx, input.Comment) + if err != nil { + return err + } + params, err := converter.CreateEditCommentParams(edit.ID, currentUser.ID, text) if err != nil { return err }