Refactoring the photoprism package #53

* Started refactoring the photoprism package.

* A lot of more comments and eliminating utils.

* Fixed search.

* See #50 The great refactor -- Refactor config to YAML, Add Docs, Revise exported fields
This commit is contained in:
Gergely Brautigam 2018-11-01 17:01:45 +01:00 committed by Michael Mayer
parent 891870b05e
commit b202bb6cc7
20 changed files with 403 additions and 330 deletions

2
go.mod
View file

@ -50,5 +50,5 @@ require (
gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
gopkg.in/go-playground/validator.v8 v8.18.2 // indirect
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce // indirect
gopkg.in/yaml.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.2.1
)

View file

@ -40,7 +40,7 @@ func LikePhoto(router *gin.RouterGroup, conf *photoprism.Config) {
photoId, err := strconv.ParseUint(c.Param("photoId"), 10, 64)
if err == nil {
photo := search.FindPhotoById(photoId)
photo := search.FindPhotoByID(photoId)
photo.PhotoFavorite = true
conf.GetDb().Save(&photo)
c.JSON(http.StatusAccepted, http.Response{})
@ -58,7 +58,7 @@ func DislikePhoto(router *gin.RouterGroup, conf *photoprism.Config) {
photoId, err := strconv.ParseUint(c.Param("photoId"), 10, 64)
if err == nil {
photo := search.FindPhotoById(photoId)
photo := search.FindPhotoByID(photoId)
photo.PhotoFavorite = false
conf.GetDb().Save(&photo)
c.JSON(http.StatusAccepted, http.Response{})

View file

@ -25,6 +25,7 @@ func getColorNames(actualColor colorful.Color) (result []string) {
return result
}
// GetColors returns color information for a given mediafiles.
func (m *MediaFile) GetColors() (colors []string, vibrantHex string, mutedHex string) {
file, _ := os.Open(m.filename)

View file

@ -3,18 +3,21 @@ package photoprism
import (
"log"
"os"
"os/user"
"path/filepath"
"time"
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/mssql"
_ "github.com/jinzhu/gorm/dialects/mssql" // Import gorm drivers
_ "github.com/jinzhu/gorm/dialects/mysql"
_ "github.com/jinzhu/gorm/dialects/postgres"
_ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/kylelemons/go-gypsy/yaml"
. "github.com/photoprism/photoprism/internal/models"
"github.com/photoprism/photoprism/internal/models"
"github.com/urfave/cli"
)
// Config provides a struct in which application configuration is stored.
type Config struct {
Debug bool
ConfigFile string
@ -32,8 +35,12 @@ type Config struct {
db *gorm.DB
}
type ConfigValues map[string]interface{}
type configValues map[string]interface{}
// NewConfig creates a new configuration entity by using two methods.
// 1: SetValuesFromFile: This will initialize values from a yaml config file.
// 2: SetValuesFromCliContext: Which comes after SetValuesFromFile and overrides
// any previous values giving an option two override file configs through the CLI.
func NewConfig(context *cli.Context) *Config {
c := &Config{}
c.SetValuesFromFile(GetExpandedFilename(context.GlobalString("config-file")))
@ -42,6 +49,7 @@ func NewConfig(context *cli.Context) *Config {
return c
}
// SetValuesFromFile uses a yaml config file to initiate the configuration entity.
func (c *Config) SetValuesFromFile(fileName string) error {
yamlConfig, err := yaml.ReadFile(fileName)
@ -50,7 +58,6 @@ func (c *Config) SetValuesFromFile(fileName string) error {
}
c.ConfigFile = fileName
if debug, err := yamlConfig.GetBool("debug"); err == nil {
c.Debug = debug
}
@ -102,6 +109,8 @@ func (c *Config) SetValuesFromFile(fileName string) error {
return nil
}
// SetValuesFromCliContext uses values from the CLI to setup configuration overrides
// for the entity.
func (c *Config) SetValuesFromCliContext(context *cli.Context) error {
if context.GlobalBool("debug") {
c.Debug = context.GlobalBool("debug")
@ -142,6 +151,11 @@ func (c *Config) SetValuesFromCliContext(context *cli.Context) error {
return nil
}
// CreateDirectories creates all the folders that photoprism needs. These are:
// OriginalsPath
// ThumbnailsPath
// ImportPath
// ExportPath
func (c *Config) CreateDirectories() error {
if err := os.MkdirAll(c.OriginalsPath, os.ModePerm); err != nil {
return err
@ -162,7 +176,9 @@ func (c *Config) CreateDirectories() error {
return nil
}
func (c *Config) ConnectToDatabase() error {
// connectToDatabase estabilishes a connection to a database given a driver.
// It tries to do this 12 times with a 5 second sleep intervall in between.
func (c *Config) connectToDatabase() error {
db, err := gorm.Open(c.DatabaseDriver, c.DatabaseDsn)
if err != nil || db == nil {
@ -186,52 +202,68 @@ func (c *Config) ConnectToDatabase() error {
return err
}
// GetAssetsPath returns the path to the assets.
func (c *Config) GetAssetsPath() string {
return c.AssetsPath
}
// GetTensorFlowModelPath returns the tensorflow model path.
func (c *Config) GetTensorFlowModelPath() string {
return c.GetAssetsPath() + "/tensorflow"
}
// GetTemplatesPath returns the templates path.
func (c *Config) GetTemplatesPath() string {
return c.GetAssetsPath() + "/templates"
}
// GetFaviconsPath returns the favicons path.
func (c *Config) GetFaviconsPath() string {
return c.GetAssetsPath() + "/favicons"
}
// GetPublicPath returns the public path.
func (c *Config) GetPublicPath() string {
return c.GetAssetsPath() + "/public"
}
// GetPublicBuildPath returns the public build path.
func (c *Config) GetPublicBuildPath() string {
return c.GetPublicPath() + "/build"
}
// GetDb gets a db connection. If it already is estabilished it will return that.
func (c *Config) GetDb() *gorm.DB {
if c.db == nil {
c.ConnectToDatabase()
c.connectToDatabase()
}
return c.db
}
// MigrateDb will start a migration process.
func (c *Config) MigrateDb() {
db := c.GetDb()
db.AutoMigrate(&File{}, &Photo{}, &Tag{}, &Album{}, &Location{}, &Camera{}, &Lens{}, &Country{})
db.AutoMigrate(&models.File{},
&models.Photo{},
&models.Tag{},
&models.Album{},
&models.Location{},
&models.Camera{},
&models.Lens{},
&models.Country{})
if !db.Dialect().HasIndex("photos", "photos_fulltext") {
db.Exec("CREATE FULLTEXT INDEX photos_fulltext ON photos (photo_title, photo_description, photo_artist, photo_colors)")
}
}
func (c *Config) GetClientConfig() ConfigValues {
// GetClientConfig returns a loaded and set configuration entity.
func (c *Config) GetClientConfig() map[string]interface{} {
db := c.GetDb()
var cameras []*Camera
var cameras []*models.Camera
type country struct {
LocCountry string
@ -240,14 +272,14 @@ func (c *Config) GetClientConfig() ConfigValues {
var countries []country
db.Model(&Location{}).Select("DISTINCT loc_country_code, loc_country").Scan(&countries)
db.Model(&models.Location{}).Select("DISTINCT loc_country_code, loc_country").Scan(&countries)
db.Where("deleted_at IS NULL").Limit(1000).Order("camera_model").Find(&cameras)
jsHash := fileHash(c.GetPublicBuildPath() + "/app.js")
cssHash := fileHash(c.GetPublicBuildPath() + "/app.css")
result := ConfigValues{
result := configValues{
"title": "PhotoPrism",
"debug": c.Debug,
"cameras": cameras,
@ -258,3 +290,21 @@ func (c *Config) GetClientConfig() ConfigValues {
return result
}
// GetExpandedFilename returns the expanded format for a filename.
func GetExpandedFilename(filename string) string {
usr, _ := user.Current()
dir := usr.HomeDir
if filename == "" {
panic("filename was empty")
}
if len(filename) > 2 && filename[:2] == "~/" {
filename = filepath.Join(dir, filename[2:])
}
result, _ := filepath.Abs(filename)
return result
}

View file

@ -1,9 +1,15 @@
package photoprism
import (
"archive/zip"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/jinzhu/gorm"
@ -12,7 +18,7 @@ import (
)
const testDataPath = "testdata"
const testDataUrl = "https://www.dropbox.com/s/na9p9wwt98l7m5b/import.zip?dl=1"
const testDataURL = "https://www.dropbox.com/s/na9p9wwt98l7m5b/import.zip?dl=1"
const testDataHash = "ed3bdb2fe86ea662bc863b63e219b47b8d9a74024757007f7979887d"
const testConfigFile = "../../configs/photoprism.yml"
@ -44,9 +50,9 @@ func (c *Config) DownloadTestData(t *testing.T) {
}
if !fileExists(testDataZip) {
fmt.Printf("Downloading latest test data zip file from %s\n", testDataUrl)
fmt.Printf("Downloading latest test data zip file from %s\n", testDataURL)
if err := downloadFile(testDataZip, testDataUrl); err != nil {
if err := downloadFile(testDataZip, testDataURL); err != nil {
fmt.Printf("Download failed: %s\n", err.Error())
}
}
@ -131,9 +137,104 @@ func TestConfig_SetValuesFromFile(t *testing.T) {
func TestConfig_ConnectToDatabase(t *testing.T) {
c := NewTestConfig()
c.ConnectToDatabase()
c.connectToDatabase()
db := c.GetDb()
assert.IsType(t, &gorm.DB{}, db)
}
func unzip(src, dest string) ([]string, error) {
var filenames []string
r, err := zip.OpenReader(src)
if err != nil {
return filenames, err
}
defer r.Close()
for _, f := range r.File {
// Skip directories like __OSX
if strings.HasPrefix(f.Name, "__") {
continue
}
rc, err := f.Open()
if err != nil {
return filenames, err
}
defer rc.Close()
// Store filename/path for returning and using later on
fpath := filepath.Join(dest, f.Name)
filenames = append(filenames, fpath)
if f.FileInfo().IsDir() {
// Make Folder
os.MkdirAll(fpath, os.ModePerm)
} else {
// Make File
var fdir string
if lastIndex := strings.LastIndex(fpath, string(os.PathSeparator)); lastIndex > -1 {
fdir = fpath[:lastIndex]
}
err = os.MkdirAll(fdir, os.ModePerm)
if err != nil {
log.Fatal(err)
return filenames, err
}
f, err := os.OpenFile(
fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return filenames, err
}
defer f.Close()
_, err = io.Copy(f, rc)
if err != nil {
return filenames, err
}
}
}
return filenames, nil
}
func downloadFile(filepath string, url string) (err error) {
// Create the file
out, err := os.Create(filepath)
if err != nil {
return err
}
defer out.Close()
// Get the data
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
// Check server response
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %s", resp.Status)
}
// Writer the body to file
_, err = io.Copy(out, resp.Body)
if err != nil {
return err
}
return nil
}

View file

@ -1,3 +0,0 @@
package photoprism
const PerceptualHashSize = 4

View file

@ -8,10 +8,13 @@ import (
"path/filepath"
)
// Converter wraps a darktable cli binary.
type Converter struct {
darktableCli string
}
// NewConverter returns a new converter by setting the darktable
// cli binary location.
func NewConverter(darktableCli string) *Converter {
if stat, err := os.Stat(darktableCli); err != nil {
log.Print("Darktable CLI binary could not be found at " + darktableCli)
@ -22,6 +25,8 @@ func NewConverter(darktableCli string) *Converter {
return &Converter{darktableCli: darktableCli}
}
// ConvertAll converts all the files given a path to JPEG. This function
// ignores error during this process.
func (c *Converter) ConvertAll(path string) {
err := filepath.Walk(path, func(filename string, fileInfo os.FileInfo, err error) error {
@ -52,6 +57,7 @@ func (c *Converter) ConvertAll(path string) {
}
}
// ConvertToJpeg converts a single image the JPEG format.
func (c *Converter) ConvertToJpeg(image *MediaFile) (*MediaFile, error) {
if !image.Exists() {
return nil, fmt.Errorf("can not convert, file does not exist: %s", image.GetFilename())

View file

@ -10,6 +10,7 @@ import (
"github.com/rwcarlsen/goexif/mknote"
)
// ExifData returns information about a single image.
type ExifData struct {
DateTime time.Time
Artist string
@ -28,6 +29,7 @@ type ExifData struct {
Orientation int
}
// GetExifData return ExifData given a single mediaFile.
func (m *MediaFile) GetExifData() (*ExifData, error) {
if m == nil {
return nil, errors.New("media file is null")
@ -116,8 +118,8 @@ func (m *MediaFile) GetExifData() (*ExifData, error) {
m.exifData.Thumbnail = thumbnail
}
if uniqueId, err := x.Get(exif.ImageUniqueID); err == nil {
m.exifData.UniqueID = uniqueId.String()
if uniqueID, err := x.Get(exif.ImageUniqueID); err == nil {
m.exifData.UniqueID = uniqueID.String()
}
if width, err := x.Get(exif.ImageWidth); err == nil {

View file

@ -9,6 +9,8 @@ import (
"time"
)
// FindOriginalsByDate searches the originalsPath given a time frame in the format of
// after <=> before and returns a list of results.
func FindOriginalsByDate(originalsPath string, after time.Time, before time.Time) (result []*MediaFile) {
filepath.Walk(originalsPath, func(filename string, fileInfo os.FileInfo, err error) error {
if err != nil || fileInfo.IsDir() || strings.HasPrefix(filepath.Base(filename), ".") {
@ -35,6 +37,8 @@ func FindOriginalsByDate(originalsPath string, after time.Time, before time.Time
return result
}
// ExportPhotosFromOriginals takes a list of original mediafiles and exports
// them to JPEG.
func ExportPhotosFromOriginals(originals []*MediaFile, thumbnailsPath string, exportPath string, size int) (err error) {
for _, mediaFile := range originals {

View file

@ -0,0 +1,34 @@
package photoprism
import (
"crypto/sha1"
"encoding/hex"
"io"
"os"
)
func fileExists(filename string) bool {
info, err := os.Stat(filename)
return err == nil && !info.IsDir()
}
func fileHash(filename string) string {
var result []byte
file, err := os.Open(filename)
if err != nil {
return ""
}
defer file.Close()
hash := sha1.New()
if _, err := io.Copy(hash, file); err != nil {
return ""
}
return hex.EncodeToString(hash.Sum(result))
}

View file

@ -2,6 +2,7 @@ package photoprism
import (
"fmt"
"io"
"log"
"os"
"path"
@ -10,6 +11,7 @@ import (
"strings"
)
// Importer todo: Fill me.
type Importer struct {
originalsPath string
indexer *Indexer
@ -19,6 +21,7 @@ type Importer struct {
removeEmptyDirectories bool
}
// NewImporter returns a new importer.
func NewImporter(originalsPath string, indexer *Indexer, converter *Converter) *Importer {
instance := &Importer{
originalsPath: originalsPath,
@ -32,6 +35,8 @@ func NewImporter(originalsPath string, indexer *Indexer, converter *Converter) *
return instance
}
// ImportPhotosFromDirectory imports all the photos from a given directory path.
// This function ignores errors.
func (i *Importer) ImportPhotosFromDirectory(importPath string) {
var directories []string
@ -126,6 +131,7 @@ func (i *Importer) ImportPhotosFromDirectory(importPath string) {
}
}
// GetDestinationFilename get the destination of a media file.
func (i *Importer) GetDestinationFilename(mainFile *MediaFile, mediaFile *MediaFile) (string, error) {
canonicalName := mainFile.GetCanonicalName()
fileExtension := mediaFile.GetExtension()
@ -150,3 +156,21 @@ func (i *Importer) GetDestinationFilename(mainFile *MediaFile, mediaFile *MediaF
return result, nil
}
func directoryIsEmpty(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
_, err = f.Readdirnames(1)
if err == io.EOF {
return true
}
return false
}

View file

@ -9,20 +9,23 @@ import (
"time"
"github.com/jinzhu/gorm"
. "github.com/photoprism/photoprism/internal/models"
"github.com/photoprism/photoprism/internal/models"
)
const (
IndexResultUpdated = "Updated"
IndexResultAdded = "Added"
indexResultUpdated = "Updated"
indexResultAdded = "Added"
)
// Indexer defines an indexer with originals path tensorflow and a db.
type Indexer struct {
originalsPath string
tensorFlow *TensorFlow
db *gorm.DB
}
// NewIndexer returns a new indexer.
// TODO: Is it really necessary to return a pointer?
func NewIndexer(originalsPath string, tensorFlow *TensorFlow, db *gorm.DB) *Indexer {
instance := &Indexer{
originalsPath: originalsPath,
@ -33,7 +36,9 @@ func NewIndexer(originalsPath string, tensorFlow *TensorFlow, db *gorm.DB) *Inde
return instance
}
func (i *Indexer) GetImageTags(jpeg *MediaFile) (results []*Tag) {
// getImageTags returns all tags of a given mediafile. This function returns
// an empty list in the case of an error.
func (i *Indexer) getImageTags(jpeg *MediaFile) (results []*models.Tag) {
tags, err := i.tensorFlow.GetImageTagsFromFile(jpeg.filename)
if err != nil {
@ -49,7 +54,7 @@ func (i *Indexer) GetImageTags(jpeg *MediaFile) (results []*Tag) {
return results
}
func (i *Indexer) appendTag(tags []*Tag, label string) []*Tag {
func (i *Indexer) appendTag(tags []*models.Tag, label string) []*models.Tag {
if label == "" {
return tags
}
@ -62,17 +67,17 @@ func (i *Indexer) appendTag(tags []*Tag, label string) []*Tag {
}
}
tag := NewTag(label).FirstOrCreate(i.db)
tag := models.NewTag(label).FirstOrCreate(i.db)
return append(tags, tag)
}
func (i *Indexer) IndexMediaFile(mediaFile *MediaFile) string {
var photo Photo
var file, primaryFile File
func (i *Indexer) indexMediaFile(mediaFile *MediaFile) string {
var photo models.Photo
var file, primaryFile models.File
var isPrimary = false
var colorNames []string
var tags []*Tag
var tags []*models.Tag
canonicalName := mediaFile.GetCanonicalNameFromFile()
fileHash := mediaFile.GetHash()
@ -95,13 +100,13 @@ func (i *Indexer) IndexMediaFile(mediaFile *MediaFile) string {
photo.PhotoColors = strings.Join(colorNames, ", ")
// Tags (TensorFlow)
tags = i.GetImageTags(jpeg)
tags = i.getImageTags(jpeg)
}
if location, err := mediaFile.GetLocation(); err == nil {
i.db.FirstOrCreate(location, "id = ?", location.ID)
photo.Location = location
photo.Country = NewCountry(location.LocCountryCode, location.LocCountry).FirstOrCreate(i.db)
photo.Country = models.NewCountry(location.LocCountryCode, location.LocCountry).FirstOrCreate(i.db)
tags = i.appendTag(tags, location.LocCity)
tags = i.appendTag(tags, location.LocCounty)
@ -122,7 +127,7 @@ func (i *Indexer) IndexMediaFile(mediaFile *MediaFile) string {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocCounty, location.LocCountry, mediaFile.GetDateCreated().Format("2006"))
}
} else {
var recentPhoto Photo
var recentPhoto models.Photo
if result := i.db.Order(gorm.Expr("ABS(DATEDIFF(taken_at, ?)) ASC", mediaFile.GetDateCreated())).Preload("Country").First(&recentPhoto); result.Error == nil {
if recentPhoto.Country != nil {
@ -143,8 +148,8 @@ func (i *Indexer) IndexMediaFile(mediaFile *MediaFile) string {
}
}
photo.Camera = NewCamera(mediaFile.GetCameraModel(), mediaFile.GetCameraMake()).FirstOrCreate(i.db)
photo.Lens = NewLens(mediaFile.GetLensModel(), mediaFile.GetLensMake()).FirstOrCreate(i.db)
photo.Camera = models.NewCamera(mediaFile.GetCameraModel(), mediaFile.GetCameraMake()).FirstOrCreate(i.db)
photo.Lens = models.NewLens(mediaFile.GetLensModel(), mediaFile.GetLensMake()).FirstOrCreate(i.db)
photo.PhotoFocalLength = mediaFile.GetFocalLength()
photo.PhotoAperture = mediaFile.GetAperture()
@ -160,14 +165,14 @@ func (i *Indexer) IndexMediaFile(mediaFile *MediaFile) string {
photo.PhotoColors = strings.Join(colorNames, ", ")
photo.Camera = NewCamera(mediaFile.GetCameraModel(), mediaFile.GetCameraMake()).FirstOrCreate(i.db)
photo.Lens = NewLens(mediaFile.GetLensModel(), mediaFile.GetLensMake()).FirstOrCreate(i.db)
photo.Camera = models.NewCamera(mediaFile.GetCameraModel(), mediaFile.GetCameraMake()).FirstOrCreate(i.db)
photo.Lens = models.NewLens(mediaFile.GetLensModel(), mediaFile.GetLensMake()).FirstOrCreate(i.db)
photo.PhotoFocalLength = mediaFile.GetFocalLength()
photo.PhotoAperture = mediaFile.GetAperture()
}
if photo.LocationID == 0 {
var recentPhoto Photo
var recentPhoto models.Photo
if result := i.db.Order(gorm.Expr("ABS(DATEDIFF(taken_at, ?)) ASC", photo.TakenAt)).Preload("Country").First(&recentPhoto); result.Error == nil {
if recentPhoto.Country != nil {
@ -211,13 +216,14 @@ func (i *Indexer) IndexMediaFile(mediaFile *MediaFile) string {
if fileQuery.Error == nil {
i.db.Save(&file)
return IndexResultUpdated
} else {
i.db.Create(&file)
return IndexResultAdded
return indexResultUpdated
}
i.db.Create(&file)
return indexResultAdded
}
// IndexRelated will index all mediafiles which has relate to a given mediafile.
func (i *Indexer) IndexRelated(mediaFile *MediaFile) map[string]bool {
indexed := make(map[string]bool)
@ -229,7 +235,7 @@ func (i *Indexer) IndexRelated(mediaFile *MediaFile) map[string]bool {
return indexed
}
mainIndexResult := i.IndexMediaFile(mainFile)
mainIndexResult := i.indexMediaFile(mainFile)
indexed[mainFile.GetFilename()] = true
log.Printf("%s main %s file \"%s\"", mainIndexResult, mainFile.GetType(), mainFile.GetRelativeFilename(i.originalsPath))
@ -239,7 +245,7 @@ func (i *Indexer) IndexRelated(mediaFile *MediaFile) map[string]bool {
continue
}
indexResult := i.IndexMediaFile(relatedMediaFile)
indexResult := i.indexMediaFile(relatedMediaFile)
indexed[relatedMediaFile.GetFilename()] = true
log.Printf("%s related %s file \"%s\"", indexResult, relatedMediaFile.GetType(), relatedMediaFile.GetRelativeFilename(i.originalsPath))
@ -248,6 +254,7 @@ func (i *Indexer) IndexRelated(mediaFile *MediaFile) map[string]bool {
return indexed
}
// IndexAll will index all mediafiles.
func (i *Indexer) IndexAll() map[string]bool {
indexed := make(map[string]bool)

View file

@ -4,7 +4,7 @@ import (
"encoding/hex"
"fmt"
"image"
_ "image/gif"
_ "image/gif" // Import for image.
_ "image/jpeg"
_ "image/png"
"io"
@ -19,25 +19,37 @@ import (
"github.com/brett-lempereur/ish"
"github.com/djherbis/times"
. "github.com/photoprism/photoprism/internal/models"
"github.com/photoprism/photoprism/internal/models"
"github.com/steakknife/hamming"
)
const (
// FileTypeOther is an unkown file format.
FileTypeOther = "unknown"
FileTypeYaml = "yml"
FileTypeJpeg = "jpg"
FileTypeRaw = "raw"
FileTypeXmp = "xmp"
FileTypeAae = "aae"
// FileTypeYaml is a yaml file format.
FileTypeYaml = "yml"
// FileTypeJpeg is a jpeg file format.
FileTypeJpeg = "jpg"
// FileTypeRaw is a raw file format.
FileTypeRaw = "raw"
// FileTypeXmp is an xmp file format.
FileTypeXmp = "xmp"
// FileTypeAae is an aae file format.
FileTypeAae = "aae"
// FileTypeMovie is a movie file format.
FileTypeMovie = "mov"
FileTypeHEIF = "heif" // High Efficiency Image File Format
// FileTypeHEIF High Efficiency Image File Format
FileTypeHEIF = "heif" // High Efficiency Image File Format
)
const (
// MimeTypeJpeg is jpeg image type
MimeTypeJpeg = "image/jpeg"
// PerceptualHashSize defines the default hash size.
PerceptualHashSize = 4
)
// FileExtensions lists all the available and supported image file formats.
var FileExtensions = map[string]string{
".crw": FileTypeRaw,
".cr2": FileTypeRaw,
@ -93,6 +105,7 @@ var FileExtensions = map[string]string{
".x3f": FileTypeRaw,
}
// MediaFile represents a single file.
type MediaFile struct {
filename string
dateCreated time.Time
@ -104,9 +117,10 @@ type MediaFile struct {
width int
height int
exifData *ExifData
location *Location
location *models.Location
}
// NewMediaFile returns a new MediaFile.
func NewMediaFile(filename string) (*MediaFile, error) {
if !fileExists(filename) {
return nil, fmt.Errorf("file does not exist: %s", filename)
@ -120,6 +134,7 @@ func NewMediaFile(filename string) (*MediaFile, error) {
return instance, nil
}
// GetDateCreated returns the date on which a mediafile was created.
func (m *MediaFile) GetDateCreated() time.Time {
if !m.dateCreated.IsZero() {
return m.dateCreated
@ -152,6 +167,7 @@ func (m *MediaFile) GetDateCreated() time.Time {
return m.dateCreated
}
// GetCameraModel returns the camera model with which the mediafile was created.
func (m *MediaFile) GetCameraModel() string {
info, err := m.GetExifData()
@ -164,6 +180,7 @@ func (m *MediaFile) GetCameraModel() string {
return result
}
// GetCameraMake returns the make of the camera with which the file was created.
func (m *MediaFile) GetCameraMake() string {
info, err := m.GetExifData()
@ -176,6 +193,7 @@ func (m *MediaFile) GetCameraMake() string {
return result
}
// GetLensModel returns the lens model of a mediafile.
func (m *MediaFile) GetLensModel() string {
info, err := m.GetExifData()
@ -188,6 +206,7 @@ func (m *MediaFile) GetLensModel() string {
return result
}
// GetLensMake returns the make of the Lens.
func (m *MediaFile) GetLensMake() string {
info, err := m.GetExifData()
@ -200,6 +219,7 @@ func (m *MediaFile) GetLensMake() string {
return result
}
// GetFocalLength return the length of the focal for a file.
func (m *MediaFile) GetFocalLength() float64 {
info, err := m.GetExifData()
@ -212,6 +232,7 @@ func (m *MediaFile) GetFocalLength() float64 {
return result
}
// GetAperture returns the aperture with which the mediafile was created.
func (m *MediaFile) GetAperture() float64 {
info, err := m.GetExifData()
@ -224,6 +245,7 @@ func (m *MediaFile) GetAperture() float64 {
return result
}
// GetCanonicalName returns the canonical name of a mediafile.
func (m *MediaFile) GetCanonicalName() string {
var postfix string
@ -240,20 +262,24 @@ func (m *MediaFile) GetCanonicalName() string {
return result
}
// GetCanonicalNameFromFile returns the canonical name of a file derived from the image name.
func (m *MediaFile) GetCanonicalNameFromFile() string {
basename := filepath.Base(m.GetFilename())
if end := strings.Index(basename, "."); end != -1 {
return basename[:end] // Length of canonical name: 16 + 12
} else {
return basename
}
return basename
}
// GetCanonicalNameFromFileWithDirectory gets the canonical name for a mediafile
// including the directory.
func (m *MediaFile) GetCanonicalNameFromFileWithDirectory() string {
return m.GetDirectory() + string(os.PathSeparator) + m.GetCanonicalNameFromFile()
}
// GetPerceptualHash returns the perceptual hash of a mediafile.
func (m *MediaFile) GetPerceptualHash() (string, error) {
if m.perceptualHash != "" {
return m.perceptualHash, nil
@ -277,30 +303,32 @@ func (m *MediaFile) GetPerceptualHash() (string, error) {
return m.perceptualHash, nil
}
// GetPerceptualDistance returns the perceptual distance for a mediafile.
func (m *MediaFile) GetPerceptualDistance(perceptualHash string) (int, error) {
var hash1, hash2 []byte
imageHash, err := m.GetPerceptualHash()
if imageHash, err := m.GetPerceptualHash(); err != nil {
if err != nil {
return -1, err
} else {
if decoded, err := hex.DecodeString(imageHash); err != nil {
return -1, err
} else {
hash1 = decoded
}
}
if decoded, err := hex.DecodeString(perceptualHash); err != nil {
decodedImageHash, err := hex.DecodeString(imageHash)
if err != nil {
return -1, err
} else {
hash2 = decoded
}
result := hamming.Bytes(hash1, hash2)
decodedPerceptualHash, err := hex.DecodeString(perceptualHash)
if err != nil {
return -1, err
}
result := hamming.Bytes(decodedImageHash, decodedPerceptualHash)
return result, nil
}
// GetHash return a sha1 hash of a mediafile based on the filename.
func (m *MediaFile) GetHash() string {
if len(m.hash) == 0 {
m.hash = fileHash(m.GetFilename())
@ -309,7 +337,7 @@ func (m *MediaFile) GetHash() string {
return m.hash
}
// When editing photos, iPhones create additional files like IMG_E12345.JPG
// GetEditedFilename When editing photos, iPhones create additional files like IMG_E12345.JPG
func (m *MediaFile) GetEditedFilename() (result string) {
basename := filepath.Base(m.filename)
@ -320,6 +348,7 @@ func (m *MediaFile) GetEditedFilename() (result string) {
return result
}
// GetRelatedFiles returns the mediafiles which are related to a given mediafile.
func (m *MediaFile) GetRelatedFiles() (result MediaFiles, mainFile *MediaFile, err error) {
baseFilename := m.GetCanonicalNameFromFileWithDirectory()
@ -356,14 +385,17 @@ func (m *MediaFile) GetRelatedFiles() (result MediaFiles, mainFile *MediaFile, e
return result, mainFile, nil
}
// GetFilename returns the filename.
func (m *MediaFile) GetFilename() string {
return m.filename
}
// SetFilename sets the filename to the given string.
func (m *MediaFile) SetFilename(filename string) {
m.filename = filename
}
// GetRelativeFilename returns the relative filename.
func (m *MediaFile) GetRelativeFilename(directory string) string {
index := strings.Index(m.filename, directory)
@ -375,14 +407,17 @@ func (m *MediaFile) GetRelativeFilename(directory string) string {
return m.filename
}
// GetDirectory returns the directory
func (m *MediaFile) GetDirectory() string {
return filepath.Dir(m.filename)
}
// GetBasename returns the basename.
func (m *MediaFile) GetBasename() string {
return filepath.Base(m.filename)
}
// GetMimeType returns the mimetype.
func (m *MediaFile) GetMimeType() string {
if m.mimeType != "" {
return m.mimeType
@ -413,26 +448,31 @@ func (m *MediaFile) GetMimeType() string {
}
func (m *MediaFile) openFile() (*os.File, error) {
if handle, err := os.Open(m.filename); err == nil {
return handle, nil
} else {
handle, err := os.Open(m.filename)
if err != nil {
log.Println(err.Error())
return nil, err
}
return handle, nil
}
// Exists checks if a mediafile exists by filename.
func (m *MediaFile) Exists() bool {
return fileExists(m.GetFilename())
}
// Remove a mediafile.
func (m *MediaFile) Remove() error {
return os.Remove(m.GetFilename())
}
// HasSameFilename compares a mediafile with another mediafile and returns if
// their filenames are matching or not.
func (m *MediaFile) HasSameFilename(other *MediaFile) bool {
return m.GetFilename() == other.GetFilename()
}
// Move a mediafile to a new file with the filename provided in parameter.
func (m *MediaFile) Move(newFilename string) error {
if err := os.Rename(m.filename, newFilename); err != nil {
return err
@ -443,6 +483,7 @@ func (m *MediaFile) Move(newFilename string) error {
return nil
}
// Copy a mediafile to another file by destinationFilename.
func (m *MediaFile) Copy(destinationFilename string) error {
file, err := m.openFile()
@ -472,10 +513,12 @@ func (m *MediaFile) Copy(destinationFilename string) error {
return nil
}
// GetExtension returns the extension of a mediafile.
func (m *MediaFile) GetExtension() string {
return strings.ToLower(filepath.Ext(m.filename))
}
// IsJpeg return true if the given mediafile is of mimetype Jpeg.
func (m *MediaFile) IsJpeg() bool {
// Don't import/use existing thumbnail files (we create our own)
if m.GetExtension() == ".thm" {
@ -485,10 +528,12 @@ func (m *MediaFile) IsJpeg() bool {
return m.GetMimeType() == MimeTypeJpeg
}
// GetType returns the type of the mediafile.
func (m *MediaFile) GetType() string {
return FileExtensions[m.GetExtension()]
}
// HasType checks whether a mediafile is of a given type.
func (m *MediaFile) HasType(typeString string) bool {
if typeString == FileTypeJpeg {
return m.IsJpeg()
@ -497,18 +542,23 @@ func (m *MediaFile) HasType(typeString string) bool {
return m.GetType() == typeString
}
// IsRaw check whether the given mediafile is of Raw type.
func (m *MediaFile) IsRaw() bool {
return m.HasType(FileTypeRaw)
}
// IsHighEfficiencyImageFile check if a given mediafile is of HEIF type.
func (m *MediaFile) IsHighEfficiencyImageFile() bool {
return m.HasType(FileTypeHEIF)
}
// IsPhoto checks if a mediafile is a photo.
func (m *MediaFile) IsPhoto() bool {
return m.IsJpeg() || m.IsRaw() || m.IsHighEfficiencyImageFile()
}
// GetJpeg returns a new mediafile given the current one's canonical name
// plus the extension .jpg.
func (m *MediaFile) GetJpeg() (*MediaFile, error) {
if m.IsJpeg() {
return m, nil
@ -553,6 +603,7 @@ func (m *MediaFile) decodeDimensions() error {
return nil
}
// GetWidth return the width dimension of a mediafile.
func (m *MediaFile) GetWidth() int {
if m.width <= 0 {
m.decodeDimensions()
@ -561,6 +612,7 @@ func (m *MediaFile) GetWidth() int {
return m.width
}
// GetHeight returns the height dimension of a mediafile.
func (m *MediaFile) GetHeight() int {
if m.height <= 0 {
m.decodeDimensions()
@ -569,6 +621,7 @@ func (m *MediaFile) GetHeight() int {
return m.height
}
// GetAspectRatio returns the aspect ratio of a mediafile.
func (m *MediaFile) GetAspectRatio() float64 {
width := float64(m.GetWidth())
height := float64(m.GetHeight())
@ -582,10 +635,11 @@ func (m *MediaFile) GetAspectRatio() float64 {
return math.Round(aspectRatio*100) / 100
}
// GetOrientation returns the orientation of a mediafile.
func (m *MediaFile) GetOrientation() int {
if exif, err := m.GetExifData(); err == nil {
return exif.Orientation
} else {
return 1
}
return 1
}

View file

@ -1,22 +1,26 @@
package photoprism
// MediaFiles provides a Collection for mediafiles.
type MediaFiles []*MediaFile
// Len returns the length of the mediafile collection.
func (f MediaFiles) Len() int {
return len(f)
}
// Less compares two mediafiles based on filename length.
func (f MediaFiles) Less(i, j int) bool {
fileName1 := f[i].GetFilename()
fileName2 := f[j].GetFilename()
if len(fileName1) == len(fileName2) {
return fileName1 < fileName2
} else {
return len(fileName1) < len(fileName2)
}
return len(fileName1) < len(fileName2)
}
// Swap changes two files order around.
func (f MediaFiles) Swap(i, j int) {
f[i], f[j] = f[j], f[i]
}

View file

@ -7,11 +7,11 @@ import (
"strconv"
"strings"
. "github.com/photoprism/photoprism/internal/models"
"github.com/photoprism/photoprism/internal/models"
"github.com/pkg/errors"
)
type OpenstreetmapAddress struct {
type openstreetmapAddress struct {
HouseNumber string `json:"house_number"`
Road string `json:"road"`
Suburb string `json:"suburb"`
@ -24,27 +24,27 @@ type OpenstreetmapAddress struct {
CountryCode string `json:"country_code"`
}
type OpenstreetmapLocation struct {
PlaceId string `json:"place_id"`
type openstreetmapLocation struct {
PlaceID string `json:"place_id"`
Lat string `json:"lat"`
Lon string `json:"lon"`
Name string `json:"name"`
Category string `json:"category"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
Address *OpenstreetmapAddress `json:"address"`
Address *openstreetmapAddress `json:"address"`
}
// See https://wiki.openstreetmap.org/wiki/Nominatim#Reverse_Geocoding
func (m *MediaFile) GetLocation() (*Location, error) {
// GetLocation See https://wiki.openstreetmap.org/wiki/Nominatim#Reverse_Geocoding
func (m *MediaFile) GetLocation() (*models.Location, error) {
if m.location != nil {
return m.location, nil
}
location := &Location{}
location := &models.Location{}
openstreetmapLocation := &OpenstreetmapLocation{
Address: &OpenstreetmapAddress{},
openstreetmapLocation := &openstreetmapLocation{
Address: &openstreetmapAddress{},
}
if exifData, err := m.GetExifData(); err == nil {
@ -59,7 +59,7 @@ func (m *MediaFile) GetLocation() (*Location, error) {
return nil, err
}
if id, err := strconv.Atoi(openstreetmapLocation.PlaceId); err == nil && id > 0 {
if id, err := strconv.Atoi(openstreetmapLocation.PlaceID); err == nil && id > 0 {
location.ID = uint(id)
} else {
return nil, errors.New("no location found")

View file

@ -5,19 +5,22 @@ import (
"time"
"github.com/jinzhu/gorm"
. "github.com/photoprism/photoprism/internal/forms"
. "github.com/photoprism/photoprism/internal/models"
"github.com/photoprism/photoprism/internal/forms"
"github.com/photoprism/photoprism/internal/models"
)
// Search searches given an originals path and a db instance.
type Search struct {
originalsPath string
db *gorm.DB
}
// SearchCount is the total number of search hits.
type SearchCount struct {
Total int
}
// PhotoSearchResult is a found mediafile.
type PhotoSearchResult struct {
// Photo
ID uint
@ -82,6 +85,7 @@ type PhotoSearchResult struct {
Tags string
}
// NewSearch returns a new Search type with a given path and db instance.
func NewSearch(originalsPath string, db *gorm.DB) *Search {
instance := &Search{
originalsPath: originalsPath,
@ -91,7 +95,8 @@ func NewSearch(originalsPath string, db *gorm.DB) *Search {
return instance
}
func (s *Search) Photos(form PhotoSearchForm) ([]PhotoSearchResult, error) {
// Photos searches for photos based on a Form and returns a PhotoSearchResult slice.
func (s *Search) Photos(form forms.PhotoSearchForm) ([]PhotoSearchResult, error) {
q := s.db.NewScope(nil).DB()
q = q.Table("photos").
Select(`SQL_CALC_FOUND_ROWS photos.*,
@ -177,26 +182,31 @@ func (s *Search) Photos(form PhotoSearchForm) ([]PhotoSearchResult, error) {
return results, nil
}
func (s *Search) FindFiles(count int, offset int) (files []File) {
s.db.Where(&File{}).Limit(count).Offset(offset).Find(&files)
// FindFiles finds files returning maximum results defined by limit
// and finding them from an offest defined by offset.
func (s *Search) FindFiles(limit int, offset int) (files []models.File) {
s.db.Where(&models.File{}).Limit(limit).Offset(offset).Find(&files)
return files
}
func (s *Search) FindFileById(id string) (file File) {
// FindFileByID returns a mediafile given a certain ID.
func (s *Search) FindFileByID(id string) (file models.File) {
s.db.Where("id = ?", id).First(&file)
return file
}
func (s *Search) FindFileByHash(fileHash string) (file File) {
// FindFileByHash finds a file with a given hash string.
func (s *Search) FindFileByHash(fileHash string) (file models.File) {
s.db.Where("file_hash = ?", fileHash).First(&file)
return file
}
func (s *Search) FindPhotoById(photoId uint64) (photo Photo) {
s.db.Where("id = ?", photoId).First(&photo)
// FindPhotoByID returns a Photo based on the ID.
func (s *Search) FindPhotoByID(photoID uint64) (photo models.Photo) {
s.db.Where("id = ?", photoID).First(&photo)
return photo
}

View file

@ -12,27 +12,32 @@ import (
"github.com/tensorflow/tensorflow/tensorflow/go/op"
)
// TensorFlow if a tensorflow wrapper given a graph, labels and a modelPath.
type TensorFlow struct {
modelPath string
graph *tf.Graph
labels []string
}
// NewTensorFlow returns a new TensorFlow.
func NewTensorFlow(tensorFlowModelPath string) *TensorFlow {
return &TensorFlow{modelPath: tensorFlowModelPath}
}
// TensorFlowLabel defines a Json struct with label and probability.
type TensorFlowLabel struct {
Label string `json:"label"`
Probability float32 `json:"probability"`
}
// TensorFlowLabels is a slice of tensorflow labels.
type TensorFlowLabels []TensorFlowLabel
func (a TensorFlowLabels) Len() int { return len(a) }
func (a TensorFlowLabels) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a TensorFlowLabels) Less(i, j int) bool { return a[i].Probability > a[j].Probability }
// GetImageTagsFromFile returns a slice of tags given a mediafile filename.
func (t *TensorFlow) GetImageTagsFromFile(filename string) (result []TensorFlowLabel, err error) {
imageBuffer, err := ioutil.ReadFile(filename)
@ -43,6 +48,7 @@ func (t *TensorFlow) GetImageTagsFromFile(filename string) (result []TensorFlowL
return t.GetImageTags(string(imageBuffer))
}
// GetImageTags returns the tags for a given image.
func (t *TensorFlow) GetImageTags(image string) (result []TensorFlowLabel, err error) {
if err := t.loadModel(); err != nil {
return nil, err

View file

@ -10,6 +10,7 @@ import (
"github.com/disintegration/imaging"
)
// CreateThumbnailsFromOriginals create thumbnails.
func CreateThumbnailsFromOriginals(originalsPath string, thumbnailsPath string, size int, square bool) {
err := filepath.Walk(originalsPath, func(filename string, fileInfo os.FileInfo, err error) error {
if err != nil || fileInfo.IsDir() || strings.HasPrefix(filepath.Base(filename), ".") {
@ -44,6 +45,7 @@ func CreateThumbnailsFromOriginals(originalsPath string, thumbnailsPath string,
}
}
// GetThumbnail get the thumbnail for a path.
func (m *MediaFile) GetThumbnail(path string, size int) (result *MediaFile, err error) {
canonicalName := m.GetCanonicalName()
dateCreated := m.GetDateCreated()
@ -61,7 +63,7 @@ func (m *MediaFile) GetThumbnail(path string, size int) (result *MediaFile, err
return m.CreateThumbnail(thumbnailFilename, size)
}
// Resize preserving the aspect ratio
// CreateThumbnail Resize preserving the aspect ratio
func (m *MediaFile) CreateThumbnail(filename string, size int) (result *MediaFile, err error) {
img, err := imaging.Open(m.filename, imaging.AutoOrientation(true))
@ -82,6 +84,7 @@ func (m *MediaFile) CreateThumbnail(filename string, size int) (result *MediaFil
return NewMediaFile(filename)
}
// GetSquareThumbnail return the square thumbnail for a path and size.
func (m *MediaFile) GetSquareThumbnail(path string, size int) (result *MediaFile, err error) {
canonicalName := m.GetCanonicalName()
dateCreated := m.GetDateCreated()
@ -99,7 +102,7 @@ func (m *MediaFile) GetSquareThumbnail(path string, size int) (result *MediaFile
return m.CreateSquareThumbnail(thumbnailFilename, size)
}
// Resize and crop to square format
// CreateSquareThumbnail Resize and crop to square format
func (m *MediaFile) CreateSquareThumbnail(filename string, size int) (result *MediaFile, err error) {
img, err := imaging.Open(m.filename, imaging.AutoOrientation(true))

View file

@ -1,198 +0,0 @@
package photoprism
import (
"archive/zip"
"crypto/sha1"
"encoding/hex"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"os"
"os/user"
"path/filepath"
"strings"
"time"
)
func GetExpandedFilename(filename string) string {
usr, _ := user.Current()
dir := usr.HomeDir
if filename == "" {
panic("filename was empty")
}
if len(filename) > 2 && filename[:2] == "~/" {
filename = filepath.Join(dir, filename[2:])
}
result, _ := filepath.Abs(filename)
return result
}
func getRandomInt(min, max int) int {
rand.Seed(time.Now().Unix())
return rand.Intn(max-min) + min
}
func fileExists(filename string) bool {
info, err := os.Stat(filename)
return err == nil && !info.IsDir()
}
func pathExists(pathname string) bool {
info, err := os.Stat(pathname)
return err == nil && info.IsDir()
}
func fileHash(filename string) string {
var result []byte
file, err := os.Open(filename)
if err != nil {
return ""
}
defer file.Close()
hash := sha1.New()
if _, err := io.Copy(hash, file); err != nil {
return ""
}
return hex.EncodeToString(hash.Sum(result))
}
func directoryIsEmpty(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
_, err = f.Readdirnames(1)
if err == io.EOF {
return true
}
return false
}
func unzip(src, dest string) ([]string, error) {
var filenames []string
r, err := zip.OpenReader(src)
if err != nil {
return filenames, err
}
defer r.Close()
for _, f := range r.File {
// Skip directories like __OSX
if strings.HasPrefix(f.Name, "__") {
continue
}
rc, err := f.Open()
if err != nil {
return filenames, err
}
defer rc.Close()
// Store filename/path for returning and using later on
fpath := filepath.Join(dest, f.Name)
filenames = append(filenames, fpath)
if f.FileInfo().IsDir() {
// Make Folder
os.MkdirAll(fpath, os.ModePerm)
} else {
// Make File
var fdir string
if lastIndex := strings.LastIndex(fpath, string(os.PathSeparator)); lastIndex > -1 {
fdir = fpath[:lastIndex]
}
err = os.MkdirAll(fdir, os.ModePerm)
if err != nil {
log.Fatal(err)
return filenames, err
}
f, err := os.OpenFile(
fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return filenames, err
}
defer f.Close()
_, err = io.Copy(f, rc)
if err != nil {
return filenames, err
}
}
}
return filenames, nil
}
func downloadFile(filepath string, url string) (err error) {
// Create the file
out, err := os.Create(filepath)
if err != nil {
return err
}
defer out.Close()
// Get the data
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
// Check server response
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %s", resp.Status)
}
// Writer the body to file
_, err = io.Copy(out, resp.Body)
if err != nil {
return err
}
return nil
}
func uniqueStrings(input []string) []string {
u := make([]string, 0, len(input))
m := make(map[string]bool)
for _, val := range input {
if !m[val] && val != "" {
m[val] = true
u = append(u, val)
}
}
return u
}

View file

@ -1,32 +0,0 @@
package photoprism
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetRandomInt(t *testing.T) {
min := 5
max := 50
for i := 0; i < 10; i++ {
result := getRandomInt(min, max)
if result > max {
t.Errorf("Random result must not be bigger than %d", max)
} else if result < min {
t.Errorf("Random result must not be smaller than %d", min)
}
}
}
func TestUniqueStrings(t *testing.T) {
input := []string{"zzz", "AAA", "ZZZ", "aaa", "foo", "1", "", "zzz", "AAA", "ZZZ", "aaa"}
output := uniqueStrings(input)
expected := []string{"foo", "1", "zzz", "AAA", "ZZZ", "aaa"}
assert.ElementsMatch(t, expected, output)
}