CLI: Add txt.JoinAnd() helper function to format lists of items #5233

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-09-29 19:25:47 +02:00
parent 7ae7ce9edd
commit e21174c297
7 changed files with 68 additions and 38 deletions

View File

@@ -5,7 +5,6 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/dustin/go-humanize/english" "github.com/dustin/go-humanize/english"
@@ -22,6 +21,7 @@ import (
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/i18n" "github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/txt"
) )
const ( const (
@@ -118,7 +118,7 @@ func StartImport(router *gin.RouterGroup) {
// Add imported files to albums if allowed. // Add imported files to albums if allowed.
if len(frm.Albums) > 0 && if len(frm.Albums) > 0 &&
acl.Rules.AllowAny(acl.ResourceAlbums, s.GetUserRole(), acl.Permissions{acl.ActionCreate, acl.ActionUpload}) { acl.Rules.AllowAny(acl.ResourceAlbums, s.GetUserRole(), acl.Permissions{acl.ActionCreate, acl.ActionUpload}) {
log.Debugf("import: adding files to album %s", clean.Log(strings.Join(frm.Albums, " and "))) log.Debugf("import: adding files to album %s", clean.Log(txt.JoinAnd(frm.Albums)))
opt.Albums = frm.Albums opt.Albums = frm.Albums
} }

View File

@@ -23,6 +23,7 @@ import (
"github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/i18n" "github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/media" "github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/txt"
) )
// UploadUserFiles adds files to the user's upload folder from where they can be processed and indexed. // UploadUserFiles adds files to the user's upload folder from where they can be processed and indexed.
@@ -329,7 +330,7 @@ func ProcessUserUpload(router *gin.RouterGroup) {
// Add imported files to albums if allowed. // Add imported files to albums if allowed.
if len(frm.Albums) > 0 && if len(frm.Albums) > 0 &&
acl.Rules.AllowAny(acl.ResourceAlbums, s.GetUserRole(), acl.Permissions{acl.ActionCreate, acl.ActionUpload}) { acl.Rules.AllowAny(acl.ResourceAlbums, s.GetUserRole(), acl.Permissions{acl.ActionCreate, acl.ActionUpload}) {
log.Debugf("upload: adding files to album %s", clean.Log(strings.Join(frm.Albums, " and "))) log.Debugf("upload: adding files to album %s", clean.Log(txt.JoinAnd(frm.Albums)))
opt.Albums = frm.Albums opt.Albums = frm.Albums
} }

View File

@@ -13,6 +13,7 @@ import (
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/entity/search" "github.com/photoprism/photoprism/internal/entity/search"
"github.com/photoprism/photoprism/internal/workers" "github.com/photoprism/photoprism/internal/workers"
"github.com/photoprism/photoprism/pkg/txt"
) )
// VisionResetCommand configures the command name, flags, and action. // VisionResetCommand configures the command name, flags, and action.
@@ -70,7 +71,7 @@ func visionResetAction(ctx *cli.Context) error {
confirmed := RunNonInteractively(ctx.Bool("yes")) confirmed := RunNonInteractively(ctx.Bool("yes"))
if !confirmed && len(selectedModels) > 0 { if !confirmed && len(selectedModels) > 0 {
label := fmt.Sprintf("Reset generated %s for matching pictures?", describeVisionModels(selectedModels)) label := fmt.Sprintf("Reset generated %s for matching pictures?", txt.JoinAnd(selectedModels))
prompt := promptui.Prompt{Label: label, IsConfirm: true} prompt := promptui.Prompt{Label: label, IsConfirm: true}
if _, err := prompt.Run(); err != nil { if _, err := prompt.Run(); err != nil {
return nil return nil
@@ -87,30 +88,3 @@ func visionResetAction(ctx *cli.Context) error {
) )
}) })
} }
func describeVisionModels(models []string) string {
descriptions := make([]string, 0, len(models))
for _, m := range models {
switch m {
case vision.ModelTypeCaption:
descriptions = append(descriptions, "captions")
case vision.ModelTypeLabels:
descriptions = append(descriptions, "labels")
default:
descriptions = append(descriptions, m)
}
}
switch len(descriptions) {
case 0:
return "metadata"
case 1:
return descriptions[0]
case 2:
return descriptions[0] + " and " + descriptions[1]
default:
head := strings.Join(descriptions[:len(descriptions)-1], ", ")
return head + ", and " + descriptions[len(descriptions)-1]
}
}

View File

@@ -63,10 +63,8 @@ func (w *Vision) Start(filter string, count int, models []string, customSrc stri
if n := len(models); n == 0 { if n := len(models); n == 0 {
log.Warnf("vision: no models were specified") log.Warnf("vision: no models were specified")
return nil return nil
} else if n == 1 {
log.Infof("vision: running %s model", models[0])
} else { } else {
log.Infof("vision: running %s models", strings.Join(models, " and ")) log.Infof("vision: running %s models", txt.JoinAnd(models))
} }
// Source type for AI generated data. // Source type for AI generated data.

View File

@@ -5,7 +5,6 @@ import (
"path" "path"
"runtime/debug" "runtime/debug"
"slices" "slices"
"strings"
"time" "time"
"github.com/dustin/go-humanize/english" "github.com/dustin/go-humanize/english"
@@ -19,6 +18,7 @@ import (
"github.com/photoprism/photoprism/internal/mutex" "github.com/photoprism/photoprism/internal/mutex"
"github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/txt"
) )
// Reset removes data generated by the specified model types for photos matching the search filter. // Reset removes data generated by the specified model types for photos matching the search filter.
@@ -41,10 +41,8 @@ func (w *Vision) Reset(filter string, count int, models []string, provider strin
if n := len(models); n == 0 { if n := len(models); n == 0 {
log.Warnf("vision: no models were specified") log.Warnf("vision: no models were specified")
return nil return nil
} else if n == 1 {
log.Infof("vision: resetting %s model data", models[0])
} else { } else {
log.Infof("vision: resetting %s model data", strings.Join(models, " and ")) log.Infof("vision: resetting %s model data", txt.JoinAnd(models))
} }
provider = clean.ShortTypeLower(provider) provider = clean.ShortTypeLower(provider)

36
pkg/txt/list.go Normal file
View File

@@ -0,0 +1,36 @@
package txt
// JoinAnd formats a slice of strings using commas and a localized "and" before the final element.
// Examples:
//
// []string{} => ""
// []string{"a"} => "a"
// []string{"a","b"} => "a and b"
// []string{"a","b","c"} => "a, b, and c"
func JoinAnd(values []string) string {
length := len(values)
switch length {
case 0:
return ""
case 1:
return values[0]
case 2:
return values[0] + " and " + values[1]
}
// length >= 3
result := ""
for i := 0; i < length; i++ {
switch {
case i == 0:
result = values[i]
case i == length-1:
result += ", and " + values[i]
default:
result += ", " + values[i]
}
}
return result
}

23
pkg/txt/list_test.go Normal file
View File

@@ -0,0 +1,23 @@
package txt
import "testing"
func TestJoinAnd(t *testing.T) {
tests := []struct {
name string
input []string
expect string
}{
{"empty", []string{}, ""},
{"single", []string{"caption"}, "caption"},
{"two", []string{"caption", "labels"}, "caption and labels"},
{"three", []string{"captions", "labels", "faces"}, "captions, labels, and faces"},
{"many", []string{"one", "two", "three", "four"}, "one, two, three, and four"},
}
for _, tc := range tests {
if got := JoinAnd(tc.input); got != tc.expect {
t.Fatalf("%s: expected %q, got %q", tc.name, tc.expect, got)
}
}
}