Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions internal/api/edit_comment_links_integration_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
37 changes: 37 additions & 0 deletions internal/queries/edit.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions internal/queries/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions internal/queries/sql/edit.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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[]);
88 changes: 88 additions & 0 deletions internal/service/edit/comment_links.go
Original file line number Diff line number Diff line change
@@ -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
}
119 changes: 119 additions & 0 deletions internal/service/edit/comment_links_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 6 additions & 2 deletions internal/service/edit/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
6 changes: 5 additions & 1 deletion internal/service/edit/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading