From a5bf332911b1a2de752adce8d2a20b9b7eadde70 Mon Sep 17 00:00:00 2001 From: gutmet Date: Tue, 1 Jan 2019 19:31:14 +0100 Subject: [PATCH] purge history since May 2016 --- .gitignore | 3 + License | 18 ++ Readme.md | 29 ++++ cmd/finstr.go | 21 +++ crop/crop.go | 40 +++++ finstr.go | 426 +++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 6 + initer/initer.go | 131 +++++++++++++++ 8 files changed, 674 insertions(+) create mode 100644 .gitignore create mode 100644 License create mode 100644 Readme.md create mode 100644 cmd/finstr.go create mode 100644 crop/crop.go create mode 100644 finstr.go create mode 100644 go.mod create mode 100644 initer/initer.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0727be8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +finstr +.gitdist + diff --git a/License b/License new file mode 100644 index 0000000..014d378 --- /dev/null +++ b/License @@ -0,0 +1,18 @@ +finstr: A bullshit-free image gallery generator. +Copyright (C) 2016 Alexander Weinhold + +Unless explicitly stated otherwise, the following applies: + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..39a919d --- /dev/null +++ b/Readme.md @@ -0,0 +1,29 @@ +finstr +====== + +finstr is a bullshit-free image gallery generator. It produces pages according to a template and groups images by tag or month. Releases can be found at [releases.gutmet.org](https://releases.gutmet.org). + +build +======= + +If you want to build the executable yourself, you need go1.11+ with module support. Checkout the repository and run 'go build cmd/finstr.go'. + +quick start +===== + +Put the executable somewhere in your path, create a folder for your gallery, then + +``` +> cd YOUR_FOLDER +> finstr init +``` + +Put some .jpgs inside the img folder. Tag them by creating files with the same name but .tags suffix. Each line is a tag, then + +``` +> finstr gen +> cd pages +> firefox index.html +``` + +finstr has also been integrated into the static site generator [wombat](/wombat/about). \ No newline at end of file diff --git a/cmd/finstr.go b/cmd/finstr.go new file mode 100644 index 0000000..b3d5716 --- /dev/null +++ b/cmd/finstr.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + "git.gutmet.org/finstr.git" + "git.gutmet.org/finstr.git/initer" + "git.gutmet.org/goutil.git" + "os" +) + +func main() { + commands := []goutil.Command{ + goutil.NewCommandWithFlags("init", initer.Command, "initialize finstr directory"), + goutil.NewCommandWithFlags("gen", finstr.Command, "generate gallery"), + } + err := goutil.Execute(commands) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/crop/crop.go b/crop/crop.go new file mode 100644 index 0000000..6130fd3 --- /dev/null +++ b/crop/crop.go @@ -0,0 +1,40 @@ +package crop + +import ( + "errors" + "git.gutmet.org/goutil.git" + "image" +) + +func boundInt(i, min, max int) int { + return goutil.IntMin(goutil.IntMax(i, min), max) +} + +func bound(p image.Point, bounds image.Rectangle) image.Point { + min := bounds.Min + max := bounds.Max + return image.Point{X: boundInt(p.X, min.X, max.X), Y: boundInt(p.Y, min.Y, max.Y)} +} + +type subImageSupport interface { + SubImage(rec image.Rectangle) image.Image +} + +func Crop(img image.Image, topleft image.Point, w int, h int) (image.Image, error) { + bounds := img.Bounds() + topleft = bound(topleft, bounds) + bottomright := bound(image.Point{X: topleft.X + w, Y: topleft.Y + h}, bounds) + croparea := image.Rect(topleft.X, topleft.Y, bottomright.X, bottomright.Y) + if timg, ok := img.(subImageSupport); ok { + return timg.SubImage(croparea), nil + } else { + return nil, errors.New("Crop: image format does not support 'SubImage' method (cropping not supported)") + } +} + +func CropCenter(img image.Image, w int, h int) (image.Image, error) { + bounds := img.Bounds() + center := image.Point{X: bounds.Min.X + bounds.Dx()/2, Y: bounds.Min.Y + bounds.Dy()/2} + topleft := image.Point{X: center.X - w/2, Y: center.Y - h/2} + return Crop(img, topleft, w, h) +} diff --git a/finstr.go b/finstr.go new file mode 100644 index 0000000..b9908d0 --- /dev/null +++ b/finstr.go @@ -0,0 +1,426 @@ +package finstr + +import ( + "bytes" + "errors" + "flag" + "fmt" + "git.gutmet.org/finstr.git/crop" + "git.gutmet.org/finstr.git/initer" + "git.gutmet.org/go-resize.git" + "git.gutmet.org/goutil.git" + goimg "image" + "image/jpeg" + "net/url" + "os" + "path" + "path/filepath" + "sort" + "strings" + "text/template" + "time" +) + +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 filename(s string) string { + return url.QueryEscape(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) { + fmt.Println(f + " already converted") + 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("medium images") + fmt.Println("-------------") + resizeAll(dirOrig(), dirMedium(), 0, 1080) + fmt.Println() +} + +func makeThumbnails() { + fmt.Println("thumbnails") + fmt.Println("----------") + var size uint + if artsy { + size = 300 + } else { + size = 200 + } + resizeAll(dirMedium(), dirSmall(), size, size) + fmt.Println() +} + +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 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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3a709a5 --- /dev/null +++ b/go.mod @@ -0,0 +1,6 @@ +module git.gutmet.org/finstr.git + +require ( + git.gutmet.org/go-resize.git v0.0.0-20180221191011-83c6a9932646 + git.gutmet.org/goutil.git v0.0.0-20181222191234-18f9120c8c1e +) diff --git a/initer/initer.go b/initer/initer.go new file mode 100644 index 0000000..128f4d3 --- /dev/null +++ b/initer/initer.go @@ -0,0 +1,131 @@ +package initer + +import ( + "flag" + "git.gutmet.org/goutil.git" + "os" + "path/filepath" +) + +const htmlTemplate = ` + + +{{.Title}} + + + + +

{{.Title}}

+ +

+{{.Back.Name}} + + +` + +const mdTemplate = `--- +title: {{.Title}} +--- +{{if .Links}}{{range $index, $link := .Links}}{{$link.Name}}
{{if $link.Space}}
{{end}}{{end}}{{end}} + +{{if .Images}}{{range $index, $img := .Images}}{{if $.Artsy}}

{{end}}{{end}}{{end}} +

+{{.Back.Name}} +` + +type IniterFlags struct { + Dir string + Markdown bool + Artsy bool +} + +func InitializedOrDie(dir string) { + paths := []string{imgFolder(dir), InitFlag(dir), TemplateFile(dir)} + for _, p := range paths { + if !goutil.PathExists(p) { + panic("Not a finstr folder? Did not find " + p) + } + } +} + +func imgFolder(dir string) string { + return filepath.Join(dir, "img") +} + +func InitFlag(dir string) string { + return filepath.Join(dir, ".finstr") +} + +func readme(dir string) string { + return filepath.Join(imgFolder(dir), "Readme") +} + +func TemplateFile(dir string) string { + return filepath.Join(dir, "template") +} + +func Init(fl IniterFlags) error { + dir := fl.Dir + err := os.MkdirAll(imgFolder(dir), 0755) + var style string + if fl.Artsy { + style = "artsy" + } else { + style = "square" + } + var template string + if fl.Markdown { + template = mdTemplate + } else { + template = htmlTemplate + } + err = goutil.OptDo(err, func() error { return goutil.WriteFile(InitFlag(dir), style) }) + err = goutil.OptDo(err, func() error { return goutil.WriteFile(readme(dir), "Put your .jpgs and .tags here") }) + err = goutil.OptDo(err, func() error { return goutil.WriteFile(TemplateFile(dir), template) }) + return err +} + +func Command() (goutil.CommandFlagsInit, goutil.CommandFunc) { + f := IniterFlags{} + flagsInit := func(s *flag.FlagSet) { + s.BoolVar(&f.Markdown, "markdown", false, "init with Markdown template instead of HTML") + s.StringVar(&f.Dir, "dir", "", "initialize 'dir' instead of current working directory") + s.BoolVar(&f.Artsy, "artsy", false, "mark gallery to generate thumbnails with original ratio instead of square") + } + return flagsInit, func([]string) error { + return Init(f) + } +}