finstr/finstr.go
2024-06-18 22:39:16 +02:00

442 lines
9.5 KiB
Go

package finstr
import (
"bytes"
"errors"
"flag"
"fmt"
goimg "image"
"image/jpeg"
"net/url"
"os"
"path"
"path/filepath"
"sort"
"strings"
"text/template"
"time"
"unicode"
"git.fireandbrimst.one/aw/finstr/crop"
"git.fireandbrimst.one/aw/finstr/initer"
"git.fireandbrimst.one/aw/go-resize"
goutil "git.fireandbrimst.one/aw/goutil/misc"
)
var dir string
var outdir string
var t *template.Template
var suffix string
var artsy bool
type link struct {
Path string
Name string
Space bool
}
type image struct {
FilenameMedium string
FilenameSmall string
}
type page struct {
Title string
Links []link
Images []image
Back link
Artsy bool
}
func replaceFirst(s string, search string, replace string) string {
return strings.Replace(s, search, replace, 1)
}
/*-------------------------*/
type calendarMonth struct {
year int
month int
}
func (m calendarMonth) String() string {
return fmt.Sprintf("%04d %v", m.year, time.Month(m.month).String())
}
func (m calendarMonth) laterThan(m2 calendarMonth) bool {
if m.year == m2.year {
return m.month > m2.month
} else {
return m.year > m2.year
}
}
func byMonthDesc(m []calendarMonth) func(int, int) bool {
return func(i, j int) bool { return m[i].laterThan(m[j]) }
}
/*-------------------------*/
type photo struct {
filename string
modified calendarMonth
tags []string
}
const dirImg = "img"
func dirOrig() string {
return filepath.Join(dir, dirImg)
}
func dirMedium() string {
return filepath.Join(outdir, "img_m")
}
func dirSmall() string {
return filepath.Join(outdir, "img_k")
}
func dirPages() string {
return filepath.Join(outdir, "pages")
}
func (p *photo) srcLocation(folder string) string {
return replaceFirst(p.filename, dirOrig(), filepath.Base(folder))
}
func (p *photo) filenameMedium() string {
return p.srcLocation(dirMedium())
}
func (p *photo) filenameSmall() string {
return p.srcLocation(dirSmall())
}
func (p *photo) img() image {
img := image{}
img.FilenameMedium = p.filenameMedium()
img.FilenameSmall = p.filenameSmall()
return img
}
func byModified(p []photo) func(int, int) bool {
return func(i, j int) bool {
if p[i].modified == p[j].modified {
return p[i].filename > p[j].filename
} else {
return p[i].modified.laterThan(p[j].modified)
}
}
}
/*-------------------------*/
func isASCII(r rune) bool {
return r <= 127
}
func convASCII(s string) string {
out := []rune(s)
for i, c := range out {
if (isASCII(c) && (unicode.IsLetter(c) || unicode.IsDigit(c))) || c == '.' {
// keep unchanged
} else if unicode.IsSpace(c) || unicode.IsPunct(c) {
out[i] = '-'
} else {
out[i] = 'x'
}
}
return string(out)
}
func filename(s string) string {
return url.QueryEscape(convASCII(s))
}
func filenameDate(date calendarMonth) string {
return fmt.Sprintf("%04d%02d", date.year, date.month)
}
/*-------------------------*/
func (p *page) String() string {
buf := new(bytes.Buffer)
p.Artsy = artsy
err := t.Execute(buf, p)
if err != nil {
panic(err)
}
return buf.String()
}
func (p *page) write(name string) {
goutil.OptPanic(goutil.WriteFile(filepath.Join(dirPages(), name+suffix), p.String()))
}
func singlePage(arg *pageArg, photos []photo) {
sort.Slice(photos, byModified(photos))
img := make([]image, 0)
for _, p := range photos {
img = append(img, p.img())
}
p := &page{Images: img, Title: arg.title(), Back: arg.back()}
p.write(arg.filename())
}
/*-------------------------*/
type pageArg struct {
tag string
date calendarMonth
}
func (arg *pageArg) filename() string {
if arg.tag != "" {
return filename(arg.tag)
} else {
return filenameDate(arg.date)
}
}
func (arg *pageArg) title() string {
if arg.tag != "" {
return arg.tag
} else {
return arg.date.String()
}
}
func (arg *pageArg) back() link {
if arg.tag != "" {
return link{Path: "ByTag.html", Name: "Back"}
} else {
return link{Path: "ByDate.html", Name: "Back"}
}
}
/*-------------------------*/
type generator struct {
photos []photo
}
func (g *generator) byTag() {
l := make([]link, 0)
photosWithTag := make(map[string][]photo)
for _, photo := range g.photos {
for _, tag := range photo.tags {
photosWithTag[tag] = append(photosWithTag[tag], photo)
}
}
tags := make([]string, 0)
for tag := range photosWithTag {
tags = append(tags, tag)
}
sort.Sort(sort.StringSlice(tags))
for _, tag := range tags {
photos := photosWithTag[tag]
l = append(l, link{Path: filename(tag) + ".html", Name: tag})
args := &pageArg{tag: tag}
singlePage(args, photos)
}
back := link{Path: "index.html", Name: "Back"}
p := &page{Links: l, Title: "photos by tag", Back: back}
p.write("ByTag")
}
func keys(m map[calendarMonth][]photo) []calendarMonth {
keys := make([]calendarMonth, 0)
for k := range m {
keys = append(keys, k)
}
return keys
}
func (g *generator) byDate() {
l := make([]link, 0)
photosByMonth := make(map[calendarMonth][]photo)
for _, photo := range g.photos {
month := photo.modified
photosByMonth[month] = append(photosByMonth[month], photo)
}
months := keys(photosByMonth)
sort.Slice(months, byMonthDesc(months))
for _, month := range months {
photos := photosByMonth[month]
l = append(l, link{Path: filenameDate(month) + ".html", Name: month.String(), Space: true})
arg := &pageArg{date: month}
singlePage(arg, photos)
}
back := link{Path: "index.html", Name: "Back"}
p := page{Links: l, Title: "photos by date", Back: back}
p.write("ByDate")
}
func (g *generator) index() {
byDate := link{Path: "ByDate.html", Name: "By Date", Space: true}
byTag := link{Path: "ByTag.html", Name: "By Tag"}
l := []link{byDate, byTag}
back := link{Path: "/", Name: "Home"}
p := &page{Links: l, Title: "photos", Back: back}
p.write("index")
}
func (g *generator) generate() {
photoFiles := goutil.ListFilesExt(dirOrig(), ".jpg")
tagFiles := goutil.ListFilesExt(dirOrig(), ".tags")
photos := make(map[string]*photo)
for _, photoFile := range photoFiles {
name := goutil.TrimExt(path.Base(photoFile))
stat, err := os.Stat(photoFile)
goutil.OptPanic(err)
modified := stat.ModTime()
month := calendarMonth{year: modified.Year(), month: int(modified.Month())}
photos[name] = &photo{filename: photoFile, modified: month}
}
for _, tagFile := range tagFiles {
name := goutil.TrimExt(path.Base(tagFile))
tagString, err := goutil.ReadFile(tagFile)
goutil.OptPanic(err)
tags := strings.Split(tagString, "\n")
for _, tag := range tags {
if _, ok := photos[name]; !ok {
panic("No matching photo file for " + tagFile)
}
if tag = strings.TrimSpace(tag); tag != "" {
photos[name].tags = append(photos[name].tags, tag)
}
}
}
for _, v := range photos {
g.photos = append(g.photos, *v)
}
g.byDate()
g.byTag()
g.index()
}
func resizeAll(src string, dest string, w uint, h uint) {
thumbnail := (w == h)
rsz := func(w uint, h uint, img goimg.Image) goimg.Image { return resize.Resize(w, h, img, resize.Lanczos3) }
files := goutil.ListFilesExt(src, ".jpg")
for _, f := range files {
outfile := filepath.Join(dest, strings.TrimPrefix(f, src))
if goutil.PathExists(outfile) {
continue
}
fmt.Println("Converting " + f)
file, err := os.Open(f)
goutil.OptPanic(err)
img, err := jpeg.Decode(file)
goutil.OptPanic(err)
file.Close()
var m goimg.Image
if thumbnail {
bounds := img.Bounds()
if bounds.Dx() < bounds.Dy() {
m = rsz(w, 0, img)
} else {
m = rsz(0, h, img)
}
if !artsy {
m, err = crop.CropCenter(m, int(w), int(h))
goutil.OptPanic(err)
}
} else {
m = rsz(w, h, img)
}
out, err := os.Create(outfile)
defer out.Close()
goutil.OptPanic(err)
goutil.OptPanic(jpeg.Encode(out, m, &jpeg.Options{Quality: 95}))
}
}
func boundSize() {
fmt.Println("making medium images")
resizeAll(dirOrig(), dirMedium(), 0, 1080)
}
func makeThumbnails() {
fmt.Println("making thumbnails")
var size uint
if artsy {
size = 300
} else {
size = 200
}
resizeAll(dirMedium(), dirSmall(), size, size)
}
type Flags struct {
InDir string
OutDir string
Suffix string
Clean bool
}
func ensureFolders(clean bool) {
outfolders := []string{dirMedium(), dirSmall(), dirPages()}
for _, d := range outfolders {
if clean {
goutil.OptPanic(os.RemoveAll(d))
}
goutil.OptPanic(os.MkdirAll(d, 0755))
}
}
func readStyle() {
style, err := goutil.ReadFile(initer.InitFlag(dir))
if err != nil {
panic(err)
}
if strings.TrimSpace(style) == "artsy" {
artsy = true
} else {
artsy = false
}
}
func Generate(fl Flags) (e error) {
defer func() {
if r := recover(); r != nil {
e = errors.New(fmt.Sprint("finstr: ", r))
}
}()
dir = fl.InDir
if fl.InDir != "" && fl.OutDir == "" {
outdir = fl.InDir
} else {
outdir = fl.OutDir
}
suffix = fl.Suffix
initer.InitializedOrDie(dir)
ensureFolders(fl.Clean)
readStyle()
boundSize()
makeThumbnails()
pageTemplate, err := goutil.ReadFile(initer.TemplateFile(dir))
if err != nil {
return err
}
t = template.Must(template.New("site").Parse(pageTemplate))
gen := &generator{}
gen.generate()
return nil
}
func Command() (goutil.CommandFlagsInit, goutil.CommandFunc) {
f := Flags{}
flagsInit := func(s *flag.FlagSet) {
s.StringVar(&f.InDir, "in", "", "use 'in' directory as input instead of current working directory")
s.StringVar(&f.OutDir, "out", "", "use 'out' directory as output instead of 'in' directory")
s.StringVar(&f.Suffix, "suffix", ".html", "file extension of generated pages")
s.BoolVar(&f.Clean, "clean", false, "remove old files in output directory before generating (use with caution)")
}
return flagsInit, func([]string) error {
return Generate(f)
}
}