package pictadd import ( "bytes" "context" "fmt" "image" "image/draw" "image/jpeg" "time" // Adds png support. _ "image/png" "io" "io/ioutil" "os" "path" "path/filepath" "strings" "unicode" "github.com/anthonynsimon/bild/transform" "github.com/google/uuid" "github.com/perlw/pict/internal/pkg/store" ) type FileWithMetadata struct { Filename string Tags []string Title string Timestamp int64 } func generateThumbnail(src, dst string) error { srcFile, err := os.Open(src) if err != nil { return fmt.Errorf("could not open source file: %w", err) } data, err := ioutil.ReadAll(srcFile) if err != nil { return fmt.Errorf("could not read source file: %w", err) } srcFile.Close() img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return fmt.Errorf("could not decode image: %w", err) } srcW := float64(img.Bounds().Dx()) srcH := float64(img.Bounds().Dy()) dstW, dstH := 160, 120 dstHW, dstHH := dstW/2, dstH/2 if srcW > srcH { ratio := srcH / srcW dstH = int(float64(dstW) * ratio) } else { ratio := srcW / srcH dstW = int(float64(dstH) * ratio) } resized := transform.Resize(img, dstW, dstH, transform.Lanczos) thumbImg := image.NewRGBA(image.Rect(0, 0, 160, 120)) draw.Draw( thumbImg, image.Rect( dstHW-(dstW/2), dstHH-(dstH/2), 160, 120, ), resized, image.Point{}, draw.Src, ) dstFile, err := os.Create(dst) if err != nil { return fmt.Errorf("could not create destination: %w", err) } err = jpeg.Encode(dstFile, thumbImg, nil) if err != nil { return fmt.Errorf("could not encode destination image: %w", err) } dstFile.Close() return nil } func generatePreview(src, dst string) error { srcFile, err := os.Open(src) if err != nil { return fmt.Errorf("could not open source file: %w", err) } data, err := ioutil.ReadAll(srcFile) if err != nil { return fmt.Errorf("could not read source file: %w", err) } srcFile.Close() img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return fmt.Errorf("could not decode image: %w", err) } srcW := float64(img.Bounds().Dx()) srcH := float64(img.Bounds().Dy()) dstW, dstH := 1000, 1000 if srcW > srcH { ratio := srcH / srcW dstH = int(float64(dstW) * ratio) } else { ratio := srcW / srcH dstW = int(float64(dstH) * ratio) } dstHW, dstHH := dstW/2, dstH/2 resized := transform.Resize(img, dstW, dstH, transform.Lanczos) thumbImg := image.NewRGBA(image.Rect(0, 0, dstW, dstH)) draw.Draw( thumbImg, image.Rect( dstHW-(dstW/2), dstHH-(dstH/2), dstW, dstH, ), resized, image.Point{}, draw.Src, ) dstFile, err := os.Create(dst) if err != nil { return fmt.Errorf("could not create destination: %w", err) } err = jpeg.Encode(dstFile, thumbImg, nil) if err != nil { return fmt.Errorf("could not encode destination image: %w", err) } dstFile.Close() return nil } // Option is an option for the pictadd app. type Option func(*App) // WithStoreProvider sets the store provider. func WithStoreProvider(provider store.WriteProvider) Option { return func(a *App) { a.store = provider } } // WithGoAhead skips any yes/no questions and just runs the import. func WithGoAhead() Option { return func(a *App) { a.goAhead = true } } // WithImageStorageDir specifies where to put image files on disk. func WithImageStorageDir(storageDir string) Option { return func(a *App) { a.storageDir = storageDir } } // App is the actual pictadd app. type App struct { store store.WriteProvider goAhead bool storageDir string } // New sets up a new pictadd app. func New(options ...Option) (*App, error) { var a App for _, opt := range options { opt(&a) } if a.store == nil { return nil, fmt.Errorf("missing store") } if a.storageDir == "" { return nil, fmt.Errorf("missing storage dir") } return &a, nil } // FixThumbnails loops through images and generates thumbnails. func (a *App) FixThumbnails(filesWithMetadata []FileWithMetadata) error { for _, data := range filesWithMetadata { thumbname := strings.TrimSuffix( data.Filename, filepath.Ext(data.Filename), ) + "_th.jpg" if err := generateThumbnail(data.Filename, thumbname); err != nil { fmt.Printf("could not generate thumbnail for %s: %s\n", data.Filename, err.Error()) continue } fmt.Printf("%s generated\n", thumbname) } return nil } // FixPreviews loops through images and generates thumbnails. func (a *App) FixPreviews(filesWithMetadata []FileWithMetadata) error { for _, data := range filesWithMetadata { name := strings.TrimSuffix( data.Filename, filepath.Ext(data.Filename), ) + "_p.jpg" if err := generatePreview(data.Filename, name); err != nil { fmt.Printf("could not generate preview for %s: %s\n", data.Filename, err.Error()) continue } fmt.Printf("%s generated\n", name) } return nil } // Import imports images and tags into database. func (a *App) Import(filesWithMetadata []FileWithMetadata) error { if len(filesWithMetadata) == 0 { return fmt.Errorf("no files to add") } records := make([]store.ImageRecord, len(filesWithMetadata)) for i, data := range filesWithMetadata { records[i].UUID = uuid.New().String() records[i].Filename = path.Join( a.storageDir, records[i].UUID+path.Ext(data.Filename), ) records[i].OrigFilename = data.Filename records[i].Title = data.Title records[i].Tags = data.Tags records[i].Timestamp = data.Timestamp } // TODO: Prettyprint with colors n stuff. if !a.goAhead { for _, record := range records { fmt.Printf("%s ->\n", record.OrigFilename) fmt.Printf("\t%s @ %s\n", record.Filename, time.Unix(record.Timestamp, 0).UTC().Format(time.DateTime)) fmt.Printf("\ttitle: %s\n", record.Title) fmt.Printf("\ttags: %v\n", record.Tags) } fmt.Printf( "The above actions will be commited to the image store. Proceed? (y/N): ", ) var response rune fmt.Scanf("%c", &response) if unicode.ToLower(response) != 'y' { return fmt.Errorf("aborted by user") } } // TODO: Rollback file copies. for _, record := range records { src, err := os.Open(record.OrigFilename) if err != nil { return fmt.Errorf("could not open source file: %w", err) } dstFilename := path.Join( a.storageDir, record.UUID+path.Ext(record.OrigFilename), ) dst, err := os.OpenFile(dstFilename, os.O_WRONLY|os.O_CREATE, 0755) if err != nil { return fmt.Errorf("could not open/create destination file: %w", err) } written, err := io.Copy(dst, src) if err != nil { return fmt.Errorf("could not write to destination: %w", err) } fmt.Printf( "%s->%s, copied %dkb\n", record.OrigFilename, dstFilename, written/1024, ) thumbname := strings.TrimSuffix( dstFilename, filepath.Ext(dstFilename), ) + "_th.jpg" if err := generateThumbnail(dstFilename, thumbname); err != nil { fmt.Printf( "could not generate thumbnail for %s: %s\n", dstFilename, err.Error(), ) continue } fmt.Printf("%s generated\n", thumbname) previewname := strings.TrimSuffix( dstFilename, filepath.Ext(dstFilename), ) + "_p.jpg" if err := generatePreview(dstFilename, previewname); err != nil { fmt.Printf( "could not generate preview for %s: %s\n", dstFilename, err.Error(), ) continue } fmt.Printf("%s generated\n", previewname) } // TODO: Rollback file copies. if err := a.store.StoreImageRecords( context.Background(), records, ); err != nil { return fmt.Errorf("could not store records: %w", err) } fmt.Println("~~~all done~~~") return nil }