🍯 Glaze

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
}